미디어위키:DevTools.js: 두 판 사이의 차이

편집 요약 없음
편집 요약 없음
84번째 줄: 84번째 줄:
         'MediaWiki:1950_Nation_Link_Editor.js',
         'MediaWiki:1950_Nation_Link_Editor.js',
         'MediaWiki:1950_Nation_Link_Editor.css',
         'MediaWiki:1950_Nation_Link_Editor.css',
        'MediaWiki:1950_Nation_Link_Map.json',
         'MediaWiki:Nation_List_Manager.js',
         'MediaWiki:Nation_List_Manager.js',
         'MediaWiki:Nation_List_Manager.css',
         'MediaWiki:Nation_List_Manager.css',
        'MediaWiki:1950_Nation_List.json',
         'MediaWiki:LanguageStatus.json',
         'MediaWiki:LanguageStatus.json',
         'Manage:1950_Nation_Link_Table',
         'Manage:1950_Nation_Link_Table',
         'Manage:Nation_List'
         'Manage:Nation_List'
     ];
     ];
    function getRenameSourceSeedTitles() {
        var titles = SOURCE_SEED_TITLES.slice();
        var year;
        for (year = 1950; year <= 2100; year += 10) {
            titles.push('MediaWiki:' + year + '_Nation_List.json');
            titles.push('MediaWiki:' + year + '_Nation_Link_Map.json');
        }
        return uniqueStrings(titles);
    }


     var SOURCE_TEXT_EXTENSIONS = {
     var SOURCE_TEXT_EXTENSIONS = {
346번째 줄: 356번째 줄:
         var underscored = normalized.replace(/ /g, '_');
         var underscored = normalized.replace(/ /g, '_');
         var variants = [normalized, underscored];
         var variants = [normalized, underscored];
        var body;


         if (isFileTitle(normalized)) {
         if (isFileTitle(normalized)) {
            body = getMoveTitleBody(normalized);
             variants = variants.concat(getFileTitleAliases(normalized));
             variants = variants.concat(getFileTitleAliases(normalized));
             getFileTitleAliases(normalized).forEach(function (alias) {
             getFileTitleAliases(normalized).forEach(function (alias) {
                 variants.push(alias.replace(/ /g, '_'));
                 variants.push(alias.replace(/ /g, '_'));
             });
             });
            /*
            * Nation_List JSON and several manager registries store a file as the bare
            * filename, not as File:/파일:.  A MediaWiki rename still leaves the old
            * redirect page alive, so failing to rewrite the bare filename makes both
            * the old and new names appear in CLBI lists.
            */
            if (body) {
                variants.push(body);
                variants.push(body.replace(/ /g, '_'));
            }
         }
         }


357번째 줄: 380번째 줄:
             return b.length - a.length;
             return b.length - a.length;
         });
         });
    }
    function isBareFileReferenceVariant(fromVariant, fromTitle) {
        var body;
        if (!isFileTitle(fromTitle)) return false;
        if (getMoveNamespacePrefix(fromVariant)) return false;
        body = getMoveTitleBody(fromTitle);
        return normalizeMoveCompare(fromVariant) === normalizeMoveCompare(body);
     }
     }


367번째 줄: 400번째 줄:
         if (isFileTitle(fromVariant) && toBody) {
         if (isFileTitle(fromVariant) && toBody) {
             replacement = fromPrefix ? fromPrefix + ':' + toBody : normalizedTo;
             replacement = fromPrefix ? fromPrefix + ':' + toBody : normalizedTo;
        } else if (isBareFileReferenceVariant(fromVariant, fromTitle) && toBody) {
            replacement = toBody;
         } else {
         } else {
             replacement = normalizedTo;
             replacement = normalizedTo;
456번째 줄: 491번째 줄:


             if (hasManageToolsAccess()) {
             if (hasManageToolsAccess()) {
                 titles = titles.concat(SOURCE_SEED_TITLES);
                 titles = titles.concat(getRenameSourceSeedTitles());
             }
             }
             titles.push(getDocumentToolPageName());
             titles.push(getDocumentToolPageName());
684번째 줄: 719번째 줄:
                     '<label class="dev-tools-label"><input id="dev-tools-rename-subpages" type="checkbox"> 하위 문서도 같이 이름 변경</label>' +
                     '<label class="dev-tools-label"><input id="dev-tools-rename-subpages" type="checkbox"> 하위 문서도 같이 이름 변경</label>' +
                     '<button type="button" class="dev-tools-button" id="dev-tools-rename-submit">실제 이름 변경 실행</button>' +
                     '<button type="button" class="dev-tools-button" id="dev-tools-rename-submit">실제 이름 변경 실행</button>' +
                     '<div class="dev-tools-note">문서/파일의 실제 제목을 새 이름으로 바꾸고, 기존 이름은 넘겨주기 레거시 경로로 남깁니다. MediaWiki 내부 처리는 이름 변경 API로 수행됩니다.</div>' +
                    '<button type="button" class="dev-tools-button" id="dev-tools-rename-cleanup-submit">참조/리스트만 정리</button>' +
                     '<div class="dev-tools-note">문서/파일의 실제 제목을 새 이름으로 바꿉니다. 기존 이름 레거시를 유지하면 MediaWiki redirect 문서는 남지만, 참조/리스트 정리를 통해 국가 목록·링크맵·설정 문서에서는 새 이름만 쓰게 합니다.</div>' +
                 '</div>' +
                 '</div>' +
                 '<div class="dev-tools-note">상단 문서 조작은 일반 화면에서 숨기고 이 패널로 모읍니다.</div>' +
                 '<div class="dev-tools-note">상단 문서 조작은 일반 화면에서 숨기고 이 패널로 모읍니다.</div>' +
1,494번째 줄: 1,530번째 줄:
             if (targetInput) targetInput.focus();
             if (targetInput) targetInput.focus();
         }
         }
    }
    function resolveRenameRedirectTarget(api, fromTitle) {
        return api.get({
            action: 'query',
            titles: normalizeApiTitle(fromTitle),
            redirects: 1,
            prop: 'info',
            format: 'json',
            formatversion: 2
        }).then(function (data) {
            var redirects = data && data.query ? data.query.redirects : [];
            var item = redirects && redirects.length ? redirects[0] : null;
            if (item && item.to) return item.to;
            return '';
        }).catch(function () {
            return '';
        });
    }
    function runRenameReferenceCleanupOnly() {
        var fromTitle = normalizeMoveTitle($('#dev-tools-rename-from').val());
        var toTitle = normalizeMoveTargetTitle(fromTitle, $('#dev-tools-rename-to').val());
        if (!fromTitle) {
            setStatus('현재 이름이 비어 있습니다.', 'error');
            return;
        }
        setButtonsDisabled(true);
        setStatus('참조/리스트 정리 준비 중...');
        withApi(function (api) {
            var targetPromise = toTitle ? Promise.resolve(toTitle) : resolveRenameRedirectTarget(api, fromTitle);
            targetPromise.then(function (resolvedToTitle) {
                resolvedToTitle = normalizeMoveTitle(resolvedToTitle);
                if (!resolvedToTitle) {
                    throw new Error('새 이름을 입력하거나, 현재 이름이 새 이름으로 넘겨주는 redirect여야 합니다.');
                }
                if (normalizeMoveCompare(fromTitle) === normalizeMoveCompare(resolvedToTitle)) {
                    throw new Error('현재 이름과 새 이름이 같습니다.');
                }
                setStatus('참조/리스트 정리 중...');
                return updateRenameReferences(api, fromTitle, resolvedToTitle).then(function (referenceResult) {
                    return { movedTo: resolvedToTitle, referenceResult: referenceResult };
                });
            }).then(function (result) {
                var referenceResult = result.referenceResult || { changed: 0, failed: 0, scanned: 0 };
                var message = '참조/리스트 정리 완료: ' + fromTitle + ' → ' + result.movedTo +
                    ' / 변경 ' + referenceResult.changed + '개 / 검사 ' + referenceResult.scanned + '개';
                if (referenceResult.failed) message += ' / 실패 ' + referenceResult.failed + '개';
                setStatus(message, referenceResult.failed ? 'error' : 'ok');
                setButtonsDisabled(false);
                $('#dev-tools-rename-to').val(result.movedTo);
            }).catch(function (err) {
                setButtonsDisabled(false);
                setStatus('참조/리스트 정리 실패: ' + (err && err.message ? err.message : err), 'error');
            });
        }, function () {
            setButtonsDisabled(false);
            setStatus('mediawiki.api 로드 실패', 'error');
        });
     }
     }


1,613번째 줄: 1,717번째 줄:
         $(document).on('click', '#dev-tools-rename-toggle', toggleRenameBox);
         $(document).on('click', '#dev-tools-rename-toggle', toggleRenameBox);
         $(document).on('click', '#dev-tools-rename-submit', runDocumentRename);
         $(document).on('click', '#dev-tools-rename-submit', runDocumentRename);
        $(document).on('click', '#dev-tools-rename-cleanup-submit', runRenameReferenceCleanupOnly);
         $(document).on('keydown', '#dev-tools-rename-to', function (event) {
         $(document).on('keydown', '#dev-tools-rename-to', function (event) {
             if (event.key === 'Enter') runDocumentRename();
             if (event.key === 'Enter') runDocumentRename();

2026년 7월 5일 (일) 06:04 판

(function (mw, $) {
    'use strict';

    var OWNER = 'Nxdsxn';
    var JSZIP_URL = 'https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js';
    var LANGUAGE_STATUS_TITLE = 'MediaWiki:LanguageStatus.json';
    var LANGUAGE_ORDER = ['ko', 'en', 'zh', 'ja', 'ru', 'es'];
    var LANGUAGE_LABELS = {
        ko: '한국어',
        en: 'English',
        zh: '中文',
        ja: '日本語',
        ru: 'Русский',
        es: 'Español'
    };
    var LANGUAGE_STATUS_VALUES = {
        available: true,
        wip: true,
        unavailable: true
    };

    var DOCUMENT_TOOLS_GROUPS = {
        editor: true,
        'senior-editor': true,
        'devtools-access': true
    };

    var MANAGE_TOOLS_GROUPS = {
        'senior-editor': true,
        'devtools-access': true
    };

    function isOwner() {
        return (mw.config.get('wgUserName') || '') === OWNER;
    }

    function hasAnyGroup(groupMap) {
        var groups = mw.config.get('wgUserGroups') || [];

        return groups.some(function (group) {
            return !!groupMap[String(group || '')];
        });
    }

    function hasDocumentToolsAccess() {
        return isOwner() || hasAnyGroup(DOCUMENT_TOOLS_GROUPS);
    }

    function hasManageToolsAccess() {
        return isOwner() || hasAnyGroup(MANAGE_TOOLS_GROUPS);
    }

    function hasDevToolsAccess() {
        return hasDocumentToolsAccess() || hasManageToolsAccess();
    }

    if (!hasDevToolsAccess()) return;
    if (window.DevToolsPanelInitialized) return;
    window.DevToolsPanelInitialized = true;


    var SOURCE_SEED_TITLES = [
        'MediaWiki:Common.css',
        'MediaWiki:Theme.css',
        'MediaWiki:Layout.css',
        'MediaWiki:MainPage.css',
        'MediaWiki:Components.css',
        'MediaWiki:Template.css',
        'MediaWiki:Template.Infobox.css',
        'MediaWiki:Icons.css',
        'MediaWiki:Editor.css',
        'MediaWiki:Dialogs.css',
        'MediaWiki:DevTools.css',
        'MediaWiki:AnecdoteViewer.css',
        'MediaWiki:Test.css',
        'MediaWiki:Common.js',
        'MediaWiki:Lang.js',
        'MediaWiki:DevTools.js',
        'MediaWiki:CategoryNav.js',
        'MediaWiki:AnecdoteViewer.js',
        'MediaWiki:NationsGlobe.js',
        'MediaWiki:NationsPanel.js',
        'MediaWiki:Shortcuts.js',
        'MediaWiki:1950_Nation_Link_Editor.js',
        'MediaWiki:1950_Nation_Link_Editor.css',
        'MediaWiki:Nation_List_Manager.js',
        'MediaWiki:Nation_List_Manager.css',
        'MediaWiki:LanguageStatus.json',
        'Manage:1950_Nation_Link_Table',
        'Manage:Nation_List'
    ];

    function getRenameSourceSeedTitles() {
        var titles = SOURCE_SEED_TITLES.slice();
        var year;

        for (year = 1950; year <= 2100; year += 10) {
            titles.push('MediaWiki:' + year + '_Nation_List.json');
            titles.push('MediaWiki:' + year + '_Nation_Link_Map.json');
        }

        return uniqueStrings(titles);
    }

    var SOURCE_TEXT_EXTENSIONS = {
        css: 'text/css',
        js: 'text/javascript',
        json: 'application/json',
        geojson: 'application/geo+json',
        topojson: 'application/json',
        gpx: 'application/gpx+xml',
        kml: 'application/vnd.google-earth.kml+xml',
        txt: 'text/plain',
        map: 'application/json',
        lua: 'text/plain',
        less: 'text/css',
        scss: 'text/css',
        xml: 'text/xml',
        html: 'text/html',
        htm: 'text/html',
        md: 'text/plain',
        csv: 'text/csv',
        tsv: 'text/tab-separated-values',
        yml: 'text/plain',
        yaml: 'text/plain'
    };

    var SOURCE_FILE_EXTENSIONS = {
        css: true,
        js: true,
        json: true,
        geojson: true,
        topojson: true,
        gpx: true,
        kml: true,
        kmz: true,
        gpkg: true,
        shp: true,
        shx: true,
        dbf: true,
        prj: true,
        cpg: true,
        txt: true,
        map: true,
        lua: true,
        xml: true,
        html: true,
        htm: true,
        md: true,
        csv: true,
        tsv: true,
        yml: true,
        yaml: true
    };

    var SOURCE_ALWAYS_NAMESPACE_IDS = {
        8: true,
        10: true,
        828: true
    };

    var SOURCE_ALWAYS_NAMESPACE_NAMES = {
        mediawiki: true,
        template: true,
        '틀': true,
        module: true,
        manage: true,
        '관리': true
    };


    function escapeHtml(value) {
        return String(value == null ? '' : value)
            .replace(/&/g, '&amp;')
            .replace(/</g, '&lt;')
            .replace(/>/g, '&gt;')
            .replace(/"/g, '&quot;')
            .replace(/'/g, '&#039;');
    }


    function normalizeDocumentToolPageName(value) {
        return String(value || '')
            .split('?')[0]
            .replace(/^\/index\.php\//, '')
            .replace(/_/g, ' ')
            .trim();
    }

    function getDocumentToolPageName() {
        var pageName = mw.config.get('wgPageName') || '';

        if (pageName) {
            return String(pageName).replace(/ /g, '_');
        }

        pageName = normalizeDocumentToolPageName(window.location.pathname || '');
        return pageName ? pageName.replace(/ /g, '_') : '대문';
    }

    function getDocumentToolDisplayTitle() {
        var pageName = getDocumentToolPageName();
        return normalizeDocumentToolPageName(pageName) || '현재 문서';
    }

    function getDocumentToolUrl(pageName, params) {
        pageName = pageName || getDocumentToolPageName();
        params = params || {};

        if (mw.util && typeof mw.util.getUrl === 'function') {
            return mw.util.getUrl(pageName, params);
        }

        var query = $.param(params);
        return '/index.php/' + encodeURI(String(pageName || '').replace(/ /g, '_')) + (query ? '?' + query : '');
    }

    function getDocumentToolTalkPageName() {
        var namespaceNumber = Number(mw.config.get('wgNamespaceNumber'));
        var formattedNamespaces = mw.config.get('wgFormattedNamespaces') || {};
        var title = String(mw.config.get('wgTitle') || '').trim();
        var talkNamespace;
        var prefix;

        if (!Number.isFinite(namespaceNumber) || namespaceNumber < 0 || namespaceNumber % 2 !== 0) {
            return '';
        }

        talkNamespace = namespaceNumber + 1;
        prefix = formattedNamespaces[String(talkNamespace)] || formattedNamespaces[talkNamespace] || '';

        if (!prefix || !title) {
            return '';
        }

        return prefix + ':' + title.replace(/ /g, '_');
    }

    function renderDocumentToolLink(label, href, disabled, extraClass) {
        var className = 'dev-tools-button dev-tools-link-button' + (extraClass ? ' ' + extraClass : '');

        if (disabled || !href) {
            return '<span class="' + escapeHtml(className + ' dev-tools-button-disabled') + '" aria-disabled="true">' + escapeHtml(label) + '</span>';
        }

        return '<a class="' + escapeHtml(className) + '" href="' + escapeHtml(href) + '">' + escapeHtml(label) + '</a>';
    }


    function renderDocumentToolButton(label, id, disabled, extraClass) {
        var className = 'dev-tools-button' + (extraClass ? ' ' + extraClass : '');

        if (disabled || !id) {
            return '<span class="' + escapeHtml(className + ' dev-tools-button-disabled') + '" aria-disabled="true">' + escapeHtml(label) + '</span>';
        }

        return '<button type="button" class="' + escapeHtml(className) + '" id="' + escapeHtml(id) + '">' + escapeHtml(label) + '</button>';
    }


    function normalizeMoveTitle(value) {
        var text = String(value || '')
            .split('#')[0]
            .split('?')[0]
            .trim();

        text = text.replace(/^https?:\/\/[^/]+\/index\.php\/?/i, '');
        text = text.replace(/^\/index\.php\/?/i, '');

        try {
            text = decodeURIComponent(text);
        } catch (err) {
            // Keep the original text if it is not a valid encoded title.
        }

        return text.replace(/_/g, ' ').trim();
    }

    function getMoveNamespacePrefix(title) {
        var text = normalizeMoveTitle(title);
        var index = text.indexOf(':');

        if (index <= 0) return '';

        return text.slice(0, index);
    }

    function normalizeMoveTargetTitle(fromTitle, targetTitle) {
        var from = normalizeMoveTitle(fromTitle);
        var target = normalizeMoveTitle(targetTitle);
        var prefix;

        if (!target) return '';
        if (target.indexOf(':') !== -1) return target;

        prefix = getMoveNamespacePrefix(from);
        return prefix ? prefix + ':' + target : target;
    }

    function normalizeMoveCompare(value) {
        return normalizeMoveTitle(value).replace(/_/g, ' ').trim().toLowerCase();
    }

    function isCurrentMoveSource(fromTitle) {
        return normalizeMoveCompare(fromTitle) === normalizeMoveCompare(getDocumentToolPageName());
    }

    function getMoveTitleBody(title) {
        var text = normalizeMoveTitle(title);
        var index = text.indexOf(':');
        return index > 0 ? text.slice(index + 1) : text;
    }

    function normalizeApiTitle(title) {
        return normalizeMoveTitle(title).replace(/ /g, '_');
    }

    function escapeRegExp(value) {
        return String(value == null ? '' : value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
    }

    function uniqueStrings(values) {
        var seen = {};
        var result = [];

        values.forEach(function (value) {
            value = String(value || '').trim();
            if (!value || seen[value]) return;
            seen[value] = true;
            result.push(value);
        });

        return result;
    }

    function isFileTitle(title) {
        var prefix = getMoveNamespacePrefix(title).toLowerCase();
        return prefix === 'file' || prefix === '파일';
    }

    function getFileTitleAliases(title) {
        var body = getMoveTitleBody(title);
        if (!body) return [];

        return uniqueStrings([
            '파일:' + body,
            'File:' + body,
            'file:' + body,
            'Image:' + body,
            'image:' + body
        ]);
    }

    function getTitleReferenceVariants(title) {
        var normalized = normalizeMoveTitle(title);
        var underscored = normalized.replace(/ /g, '_');
        var variants = [normalized, underscored];
        var body;

        if (isFileTitle(normalized)) {
            body = getMoveTitleBody(normalized);
            variants = variants.concat(getFileTitleAliases(normalized));
            getFileTitleAliases(normalized).forEach(function (alias) {
                variants.push(alias.replace(/ /g, '_'));
            });

            /*
             * Nation_List JSON and several manager registries store a file as the bare
             * filename, not as File:/파일:.  A MediaWiki rename still leaves the old
             * redirect page alive, so failing to rewrite the bare filename makes both
             * the old and new names appear in CLBI lists.
             */
            if (body) {
                variants.push(body);
                variants.push(body.replace(/ /g, '_'));
            }
        }

        return uniqueStrings(variants).sort(function (a, b) {
            return b.length - a.length;
        });
    }

    function isBareFileReferenceVariant(fromVariant, fromTitle) {
        var body;

        if (!isFileTitle(fromTitle)) return false;
        if (getMoveNamespacePrefix(fromVariant)) return false;

        body = getMoveTitleBody(fromTitle);
        return normalizeMoveCompare(fromVariant) === normalizeMoveCompare(body);
    }

    function getTitleReferenceReplacement(fromVariant, fromTitle, toTitle) {
        var fromPrefix = getMoveNamespacePrefix(fromVariant);
        var toBody = getMoveTitleBody(toTitle);
        var normalizedTo = normalizeMoveTitle(toTitle);
        var replacement;

        if (isFileTitle(fromVariant) && toBody) {
            replacement = fromPrefix ? fromPrefix + ':' + toBody : normalizedTo;
        } else if (isBareFileReferenceVariant(fromVariant, fromTitle) && toBody) {
            replacement = toBody;
        } else {
            replacement = normalizedTo;
        }

        if (fromVariant.indexOf('_') !== -1) {
            replacement = replacement.replace(/ /g, '_');
        }

        return replacement;
    }

    function replaceAllText(value, from, to) {
        return String(value).split(from).join(to);
    }

    function replaceRenameReferences(text, fromTitle, toTitle, includePlainText) {
        var next = String(text || '');
        var variants = getTitleReferenceVariants(fromTitle);
        var changed = false;

        variants.forEach(function (variant) {
            var replacement = getTitleReferenceReplacement(variant, fromTitle, toTitle);
            var before = next;
            var escaped = escapeRegExp(variant).replace(/[ _]+/g, '[ _]+');

            next = next.replace(new RegExp('(\\[\\[\\s*)' + escaped + '(?=\\s*(?:[\\]#|]))', 'gi'), '$1' + replacement);
            next = next.replace(new RegExp('(\\[\\[\\s*)' + escaped + '(?=\\s*\\|)', 'gi'), '$1' + replacement);

            if (includePlainText) {
                next = replaceAllText(next, variant, replacement);
            }

            if (next !== before) changed = true;
        });

        return {
            text: next,
            changed: changed || next !== String(text || '')
        };
    }

    function isRenameSourceLikeTitle(title) {
        var ext = getTitleExtension(title);
        var nsName = normalizeNamespaceName(getTitleNamespaceName(title));

        if (SOURCE_TEXT_EXTENSIONS[ext]) return true;
        if (SOURCE_ALWAYS_NAMESPACE_NAMES[nsName]) return true;
        return false;
    }

    function queryRenameReferencePages(api, fromTitle) {
        var normalized = normalizeApiTitle(fromTitle);
        var requests = [
            api.get({
                action: 'query',
                list: 'backlinks',
                bltitle: normalized,
                bllimit: 'max',
                blfilterredir: 'nonredirects',
                format: 'json',
                formatversion: 2
            }).then(function (data) {
                var list = data && data.query ? data.query.backlinks : [];
                return (list || []).map(function (item) { return item.title; });
            }).catch(function () { return []; })
        ];

        if (isFileTitle(fromTitle)) {
            requests.push(api.get({
                action: 'query',
                list: 'imageusage',
                iutitle: normalized,
                iulimit: 'max',
                format: 'json',
                formatversion: 2
            }).then(function (data) {
                var list = data && data.query ? data.query.imageusage : [];
                return (list || []).map(function (item) { return item.title; });
            }).catch(function () { return []; }));
        }

        return Promise.all(requests).then(function (chunks) {
            var titles = [];

            chunks.forEach(function (chunk) {
                titles = titles.concat(chunk || []);
            });

            if (hasManageToolsAccess()) {
                titles = titles.concat(getRenameSourceSeedTitles());
            }
            titles.push(getDocumentToolPageName());

            return uniqueStrings(titles).filter(function (title) {
                return normalizeMoveCompare(title) !== normalizeMoveCompare(fromTitle);
            });
        });
    }

    function fetchRenamePageText(api, title) {
        return api.get({
            action: 'query',
            prop: 'revisions|info',
            titles: normalizeApiTitle(title),
            rvprop: 'content|ids',
            rvslots: 'main',
            format: 'json',
            formatversion: 2
        }).then(function (data) {
            var pages = data && data.query ? data.query.pages : [];
            var page = pages && pages.length ? pages[0] : null;
            var text = '';

            if (!page || page.missing) return null;
            if (page.ns === 6) return null;

            if (page.revisions && page.revisions[0]) {
                if (page.revisions[0].slots && page.revisions[0].slots.main) {
                    text = page.revisions[0].slots.main.content || '';
                } else {
                    text = page.revisions[0].content || '';
                }
            }

            return {
                title: page.title || title,
                text: text,
                ns: page.ns,
                contentmodel: page.contentmodel || '',
                revid: page.revisions && page.revisions[0] ? page.revisions[0].revid : null
            };
        });
    }

    function editRenameReferencePage(api, page, nextText, fromTitle, toTitle) {
        var payload = {
            action: 'edit',
            title: page.title,
            text: nextText,
            summary: 'Update references after rename: ' + fromTitle + ' → ' + toTitle,
            minor: 1,
            nocreate: 1,
            format: 'json',
            formatversion: 2,
            errorformat: 'plaintext'
        };

        if (page.contentmodel) payload.contentmodel = page.contentmodel;
        if (page.revid) payload.baserevid = page.revid;

        return api.postWithToken('csrf', payload);
    }

    function updateRenameReferences(api, fromTitle, toTitle) {
        var checked = $('#dev-tools-rename-update-references').prop('checked');

        if (!checked) {
            return Promise.resolve({ scanned: 0, changed: 0, failed: 0 });
        }

        return queryRenameReferencePages(api, fromTitle).then(function (titles) {
            var total = titles.length;
            var index = 0;
            var changed = 0;
            var failed = 0;
            var chain = Promise.resolve();

            titles.forEach(function (title) {
                chain = chain.then(function () {
                    index += 1;
                    setStatus('참조 정리 중... ' + index + '/' + total);

                    return fetchRenamePageText(api, title).then(function (page) {
                        var result;
                        var includePlainText;

                        if (!page) return;

                        includePlainText = isRenameSourceLikeTitle(page.title);
                        result = replaceRenameReferences(page.text, fromTitle, toTitle, includePlainText);
                        if (!result.changed) return;

                        return editRenameReferencePage(api, page, result.text, fromTitle, toTitle).then(function () {
                            changed += 1;
                        });
                    }).catch(function () {
                        failed += 1;
                    });
                });
            });

            return chain.then(function () {
                return { scanned: total, changed: changed, failed: failed };
            });
        });
    }


    function isNationsDocumentPage() {
        var pageName = normalizeDocumentToolPageName(getDocumentToolPageName());
        var title = normalizeDocumentToolPageName(mw.config.get('wgTitle') || '');
        var candidates = [pageName, title];

        return candidates.some(function (value) {
            value = String(value || '').replace(/_/g, ' ').trim().toLowerCase();
            return value === '국가 및 조합' || value === 'nations & factions' || value === 'nations and factions';
        });
    }

    function buildNationsManageToolsHtml() {
        if (!hasManageToolsAccess()) return '';
        if (!isNationsDocumentPage()) return '';

        return '' +
            '<div class="dev-tools-section dev-tools-nations-manage-section">' +
                '<div class="dev-tools-section-title">NATIONS MANAGE</div>' +
                '<div class="dev-tools-grid dev-tools-doc-grid" id="dev-tools-nations-manage-grid">' +
                    renderDocumentToolLink('1950 링크표', getDocumentToolUrl('Manage:1950_Nation_Link_Table'), false) +
                    renderDocumentToolLink('국가 목록', getDocumentToolUrl('Manage:Nation_List'), false) +
                '</div>' +
                '<div class="dev-tools-note">국가 및 조합 전용 관리 페이지로 이동합니다.</div>' +
            '</div>';
    }

    function buildOwnerToolsHtml() {
        if (!isOwner()) return '';

        return '' +
            '<div class="dev-tools-section dev-tools-owner-section">' +
                '<div class="dev-tools-section-title">OWNER</div>' +
                '<div class="dev-tools-grid dev-tools-doc-grid" id="dev-tools-owner-grid">' +
                    renderDocumentToolLink('사용자 권한', getDocumentToolUrl('Special:UserRights'), false) +
                '</div>' +
                '<div class="dev-tools-note">사용자 그룹과 권한을 관리합니다.</div>' +
            '</div>';
    }

    function buildSourcePackageToolsHtml() {
        if (!isOwner()) return '';

        return '' +
            '<div class="dev-tools-section">' +
                '<div class="dev-tools-section-title">SOURCE PACKAGE</div>' +
                '<button type="button" class="dev-tools-button" id="dev-tools-export">위키 소스 ZIP 다운로드</button>' +
                '<div class="dev-tools-note">위키 전체를 스캔해 CSS·JS·JSON·GeoJSON·Template·Manage·Module 원본과 source-like 파일을 ZIP으로 묶습니다. SVG는 제외합니다.</div>' +
            '</div>';
    }

    function buildProgressToolsHtml() {
        if (!isOwner()) return '';

        return '' +
            '<div class="dev-tools-section dev-tools-progress-section">' +
                '<div class="dev-tools-section-title">PROGRESS TEST</div>' +
                '<div class="dev-tools-progress-readout" id="dev-tools-progress-readout">SYNC</div>' +
                '<label class="dev-tools-label" for="dev-tools-xp-amount">XP AMOUNT</label>' +
                '<input id="dev-tools-xp-amount" class="dev-tools-input" type="number" min="1" step="1" value="10">' +
                '<label class="dev-tools-label" for="dev-tools-xp-reason">REASON</label>' +
                '<input id="dev-tools-xp-reason" class="dev-tools-input" type="text" value="관리자 테스트">' +
                '<div class="dev-tools-grid">' +
                    '<button type="button" class="dev-tools-button" id="dev-tools-xp-add">XP 지급</button>' +
                    '<button type="button" class="dev-tools-button" id="dev-tools-progress-refresh">동기화</button>' +
                '</div>' +
                '<button type="button" class="dev-tools-button" id="dev-tools-daily-reset">오늘 XP 보너스 리셋</button>' +
                '<button type="button" class="dev-tools-button dev-tools-danger" id="dev-tools-level-reset">레벨 리셋</button>' +
            '</div>';
    }

    function buildLanguageStatusToolsHtml() {
        if (!isOwner()) return '';

        return '' +
            '<div class="dev-tools-section dev-tools-language-section">' +
                '<div class="dev-tools-section-title">LANGUAGE STATUS</div>' +
                '<div class="dev-tools-language-page" id="dev-tools-language-page">PAGE: SYNC</div>' +
                '<div class="dev-tools-language-list" id="dev-tools-language-list">SYNC</div>' +
                '<div class="dev-tools-grid">' +
                    '<button type="button" class="dev-tools-button" id="dev-tools-language-save">저장</button>' +
                    '<button type="button" class="dev-tools-button" id="dev-tools-language-refresh">불러오기</button>' +
                '</div>' +
                '<div class="dev-tools-note">현재 문서의 언어 다이얼 상태를 JSON으로 저장합니다.</div>' +
            '</div>';
    }

    function buildDocumentToolsHtml() {
        var namespaceNumber = Number(mw.config.get('wgNamespaceNumber'));
        var pageName = getDocumentToolPageName();
        var displayTitle = getDocumentToolDisplayTitle();
        var isSpecial = Number.isFinite(namespaceNumber) && namespaceNumber < 0;
        var talkPageName = getDocumentToolTalkPageName();

        return '' +
            '<div class="dev-tools-section dev-tools-document-section">' +
                '<div class="dev-tools-section-title">DOCUMENT TOOLS</div>' +
                '<div class="dev-tools-current-page" id="dev-tools-current-page">PAGE: ' + escapeHtml(displayTitle) + '</div>' +
                '<div class="dev-tools-grid dev-tools-doc-grid" id="dev-tools-doc-grid">' +
                    renderDocumentToolLink('문서', getDocumentToolUrl(pageName), isSpecial) +
                    renderDocumentToolLink('편집', getDocumentToolUrl(pageName, { action: 'edit' }), isSpecial) +
                    renderDocumentToolLink('원본', getDocumentToolUrl(pageName, { action: 'raw' }), isSpecial) +
                    renderDocumentToolLink('역사', getDocumentToolUrl(pageName, { action: 'history' }), isSpecial) +
                    renderDocumentToolLink('토론', talkPageName ? getDocumentToolUrl(talkPageName) : '', !talkPageName) +
                    renderDocumentToolLink('새로고침', getDocumentToolUrl(pageName, { action: 'purge' }), isSpecial) +
                    renderDocumentToolButton('이름변경', 'dev-tools-rename-toggle', isSpecial) +
                    renderDocumentToolLink('삭제', getDocumentToolUrl(pageName, { action: 'delete' }), isSpecial, 'dev-tools-danger') +
                '</div>' +
                '<div class="dev-tools-rename-box" id="dev-tools-rename-box" hidden>' +
                    '<label class="dev-tools-label" for="dev-tools-rename-from">현재 이름</label>' +
                    '<input id="dev-tools-rename-from" class="dev-tools-input" type="text" value="' + escapeHtml(displayTitle) + '">' +
                    '<label class="dev-tools-label" for="dev-tools-rename-to">새 이름</label>' +
                    '<input id="dev-tools-rename-to" class="dev-tools-input" type="text" placeholder="새 문서명 또는 파일명">' +
                    '<label class="dev-tools-label" for="dev-tools-rename-reason">사유</label>' +
                    '<input id="dev-tools-rename-reason" class="dev-tools-input" type="text" value="이름 변경">' +
                    '<label class="dev-tools-label"><input id="dev-tools-rename-legacy" type="checkbox" checked> 기존 이름 레거시 유지</label>' +
                    '<label class="dev-tools-label"><input id="dev-tools-rename-update-references" type="checkbox" checked> 사용처와 설정 참조 정리</label>' +
                    '<label class="dev-tools-label"><input id="dev-tools-rename-talk" type="checkbox" checked> 토론 문서도 같이 이름 변경</label>' +
                    '<label class="dev-tools-label"><input id="dev-tools-rename-subpages" type="checkbox"> 하위 문서도 같이 이름 변경</label>' +
                    '<button type="button" class="dev-tools-button" id="dev-tools-rename-submit">실제 이름 변경 실행</button>' +
                    '<button type="button" class="dev-tools-button" id="dev-tools-rename-cleanup-submit">참조/리스트만 정리</button>' +
                    '<div class="dev-tools-note">문서/파일의 실제 제목을 새 이름으로 바꿉니다. 기존 이름 레거시를 유지하면 MediaWiki redirect 문서는 남지만, 참조/리스트 정리를 통해 국가 목록·링크맵·설정 문서에서는 새 이름만 쓰게 합니다.</div>' +
                '</div>' +
                '<div class="dev-tools-note">상단 문서 조작은 일반 화면에서 숨기고 이 패널로 모읍니다.</div>' +
            '</div>';
    }

    function refreshDocumentTools() {
        var $section = $('.dev-tools-document-section');

        if (!$section.length) return;

        $section.replaceWith(buildDocumentToolsHtml());
    }

    function buildPanel() {
        if ($('#dev-tools-panel').length) return;

        var collapsed = localStorage.getItem('dev-tools-collapsed') === 'true';
        var html = '' +
            '<div id="dev-tools-panel" class="' + (collapsed ? 'dev-tools-collapsed' : '') + '">' +
                '<div id="dev-tools-header">' +
                    '<div id="dev-tools-title"><span class="dev-tools-led"></span><span>ADMIN TOOLS</span></div>' +
                    '<button type="button" id="dev-tools-toggle" aria-label="도구 접기">' + (collapsed ? '+' : '−') + '</button>' +
                '</div>' +
                '<div id="dev-tools-body">' +
                    buildOwnerToolsHtml() +
                    buildDocumentToolsHtml() +
                    buildNationsManageToolsHtml() +
                    buildSourcePackageToolsHtml() +
                    buildProgressToolsHtml() +
                    buildLanguageStatusToolsHtml() +
                    '<div id="dev-tools-status">READY</div>' +
                '</div>' +
            '</div>';

        $('body').append(html);
    }

    function setStatus(text, type) {
        var $status = $('#dev-tools-status');
        $status.removeClass('dev-tools-status-ok dev-tools-status-error');
        if (type === 'ok') $status.addClass('dev-tools-status-ok');
        if (type === 'error') $status.addClass('dev-tools-status-error');
        $status.text(text || '');
    }

    function setButtonsDisabled(disabled) {
        $('#dev-tools-panel button, #dev-tools-panel input, #dev-tools-panel select').prop('disabled', !!disabled);
        $('#dev-tools-toggle').prop('disabled', false);
    }

    function loadScript(url) {
        return new Promise(function (resolve, reject) {
            if (window.JSZip) {
                resolve();
                return;
            }

            var script = document.createElement('script');
            script.src = url;
            script.onload = resolve;
            script.onerror = reject;
            document.head.appendChild(script);
        });
    }


    function getTitleExtension(title) {
        var match = String(title || '').split('?')[0].split('#')[0].match(/\.([A-Za-z0-9]+)$/);
        return match ? match[1].toLowerCase() : '';
    }

    function normalizeNamespaceName(name) {
        return String(name || '').replace(/_/g, ' ').trim().toLowerCase();
    }

    function getTitleNamespaceName(title) {
        var text = String(title || '');
        var idx = text.indexOf(':');
        return idx === -1 ? '' : text.slice(0, idx);
    }

    function getTitleBody(title) {
        var text = String(title || '');
        var idx = text.indexOf(':');
        return idx === -1 ? text : text.slice(idx + 1);
    }

    function sanitizePathPart(value) {
        var text = String(value == null ? '' : value).trim();
        if (!text) return '_';
        return text
            .replace(/[\\:*?"<>|]/g, '_')
            .replace(/\s+/g, ' ')
            .replace(/^\.+$/, '_')
            .replace(/^\.+/, '')
            .replace(/\.+$/, '') || '_';
    }

    function sanitizeTitlePath(value) {
        return String(value || '')
            .replace(/_/g, ' ')
            .split('/')
            .map(sanitizePathPart)
            .join('/');
    }

    function namespaceFolder(source) {
        var nsName = source.namespaceName || getTitleNamespaceName(source.title);
        var normalized = normalizeNamespaceName(nsName);

        if (source.ns === 6 || normalized === 'file' || normalized === '파일') return 'file';
        if (source.ns === 8 || normalized === 'mediawiki') return 'mediawiki';
        if (source.ns === 10 || normalized === 'template' || normalized === '틀') return 'template';
        if (source.ns === 828 || normalized === 'module') return 'module';
        if (normalized === 'manage' || normalized === '관리') return 'manage';
        if (!normalized) return 'page';
        return sanitizePathPart(normalized);
    }

    function contentTypeForTitle(title) {
        return SOURCE_TEXT_EXTENSIONS[getTitleExtension(title)] || 'text/plain';
    }

    function outputPathForSource(source) {
        var title = source.title || '';
        var body = getTitleBody(title);
        var ext = getTitleExtension(title);
        var folder;

        if ((source.ns === 8 || normalizeNamespaceName(getTitleNamespaceName(title)) === 'mediawiki') && ext === 'css') {
            return 'css/' + sanitizeTitlePath(body);
        }
        if ((source.ns === 8 || normalizeNamespaceName(getTitleNamespaceName(title)) === 'mediawiki') && ext === 'js') {
            return 'js/' + sanitizeTitlePath(body);
        }

        folder = namespaceFolder(source);
        return folder + '/' + sanitizeTitlePath(body || title);
    }

    function rawUrl(file) {
        return mw.util.getUrl(file.title, {
            action: 'raw',
            ctype: file.ctype || contentTypeForTitle(file.title)
        });
    }

    function fetchRaw(file) {
        return fetch(rawUrl(file), { credentials: 'same-origin', cache: 'no-cache' }).then(function (res) {
            if (!res.ok) throw new Error(file.title + ' HTTP ' + res.status);
            return res.text();
        });
    }

    function fetchFileBlob(api, file) {
        return api.get({
            action: 'query',
            prop: 'imageinfo',
            titles: file.title,
            iiprop: 'url|mime|size',
            format: 'json',
            formatversion: 2
        }).then(function (data) {
            var pages = data && data.query ? data.query.pages : [];
            var page = pages && pages.length ? pages[0] : null;
            var info = page && page.imageinfo && page.imageinfo.length ? page.imageinfo[0] : null;
            if (!info || !info.url) throw new Error(file.title + ' file url not found');
            return fetch(info.url, { credentials: 'same-origin', cache: 'no-cache' }).then(function (res) {
                if (!res.ok) throw new Error(file.title + ' HTTP ' + res.status);
                return res.blob();
            });
        });
    }

    function isFileSourcePage(page, ext) {
        return (page.ns === 6 || normalizeNamespaceName(getTitleNamespaceName(page.title)) === 'file' || normalizeNamespaceName(getTitleNamespaceName(page.title)) === '파일') && !!SOURCE_FILE_EXTENSIONS[ext];
    }

    function shouldExportSourcePage(page, namespaceInfo) {
        var title = page && page.title ? page.title : '';
        var ext = getTitleExtension(title);
        var nsName = namespaceInfo && namespaceInfo.namesById ? namespaceInfo.namesById[page.ns] : getTitleNamespaceName(title);
        var normalized = normalizeNamespaceName(nsName || getTitleNamespaceName(title));

        if (!title) return false;
        if (ext === 'svg') return false;
        if (isFileSourcePage(page, ext)) return true;
        if (SOURCE_TEXT_EXTENSIONS[ext]) return true;
        if (SOURCE_ALWAYS_NAMESPACE_IDS[page.ns]) return true;
        if (SOURCE_ALWAYS_NAMESPACE_NAMES[normalized]) return true;
        return false;
    }

    function sourceFromTitle(title, namespaceInfo) {
        var nsName = getTitleNamespaceName(title);
        var normalizedNs = normalizeNamespaceName(nsName);
        var ns = null;

        if (namespaceInfo && namespaceInfo.idsByName && normalizedNs && namespaceInfo.idsByName.hasOwnProperty(normalizedNs)) {
            ns = namespaceInfo.idsByName[normalizedNs];
        }

        return {
            title: title,
            ns: ns,
            namespaceName: nsName,
            ctype: contentTypeForTitle(title),
            kind: (ns === 6 || normalizedNs === 'file' || normalizedNs === '파일') ? 'file' : 'page'
        };
    }

    function canonicalSourceKey(source) {
        var title = String(source && source.title ? source.title : '').replace(/_/g, ' ').trim();
        var body = getTitleBody(title).replace(/_/g, ' ').trim();
        var ns = source && source.ns != null ? String(source.ns) : '';
        var nsName = normalizeNamespaceName(source && source.namespaceName ? source.namespaceName : getTitleNamespaceName(title));

        if (!title) return '';

        /*
         * Namespace aliases can expose the same page twice during whole-wiki scan.
         * Example: MediaWiki:Common.css from the seed list and the localized namespace
         * title returned by allpages.  Deduplicate by namespace id + title body, not by
         * the literal namespace prefix, so those aliases do not become Common-2.css.
         */
        if (ns) {
            return 'ns:' + ns + ':' + body.toLowerCase();
        }

        return 'title:' + (nsName ? nsName + ':' : '') + body.toLowerCase();
    }

    function uniqueSources(sources) {
        var seen = {};
        var outPaths = {};
        var result = [];

        sources.forEach(function (source) {
            var key = canonicalSourceKey(source);
            var basePath;
            var path;
            var ext;
            var n;

            if (!key || seen[key]) return;
            seen[key] = true;

            source.ctype = source.ctype || contentTypeForTitle(source.title);
            source.kind = source.kind || (source.ns === 6 ? 'file' : 'page');
            basePath = outputPathForSource(source);
            path = basePath;
            n = 2;

            while (outPaths[path]) {
                ext = path.match(/(\.[^/.]+)$/);
                if (ext) {
                    path = basePath.slice(0, -ext[1].length) + '-' + n + ext[1];
                } else {
                    path = basePath + '-' + n;
                }
                n += 1;
            }

            outPaths[path] = true;
            source.out = path;
            result.push(source);
        });

        return result.sort(function (a, b) {
            return a.out.localeCompare(b.out);
        });
    }

    function getSourceNamespaces(api) {
        return api.get({
            action: 'query',
            meta: 'siteinfo',
            siprop: 'namespaces',
            format: 'json',
            formatversion: 2
        }).then(function (data) {
            var raw = data && data.query ? data.query.namespaces : {};
            var list = Array.isArray(raw) ? raw : Object.keys(raw || {}).map(function (key) { return raw[key]; });
            var ids = [];
            var namesById = {};
            var idsByName = {};

            list.forEach(function (item) {
                var id = parseInt(item.id != null ? item.id : item.ns, 10);
                var name = item.name || item.canonical || '';
                if (!Number.isFinite(id) || id < 0) return;
                ids.push(id);
                namesById[id] = name;
                idsByName[normalizeNamespaceName(name)] = id;
                if (item.canonical) idsByName[normalizeNamespaceName(item.canonical)] = id;
            });

            ids.sort(function (a, b) { return a - b; });
            return {
                ids: ids,
                namesById: namesById,
                idsByName: idsByName
            };
        });
    }

    function fetchAllPagesInNamespace(api, ns) {
        var pages = [];

        function next(apcontinue) {
            var params = {
                action: 'query',
                list: 'allpages',
                apnamespace: ns,
                apfilterredir: 'nonredirects',
                aplimit: 'max',
                format: 'json',
                formatversion: 2
            };
            if (apcontinue) params.apcontinue = apcontinue;

            return api.get(params).then(function (data) {
                var chunk = data && data.query ? data.query.allpages : [];
                if (chunk && chunk.length) pages = pages.concat(chunk);
                if (data && data.continue && data.continue.apcontinue) {
                    return next(data.continue.apcontinue);
                }
                return pages;
            });
        }

        return next();
    }

    function scanSourcePages(api, onProgress) {
        return getSourceNamespaces(api).then(function (namespaceInfo) {
            var all = [];
            var chain = Promise.resolve();
            var index = 0;

            namespaceInfo.ids.forEach(function (ns) {
                chain = chain.then(function () {
                    index += 1;
                    if (onProgress) onProgress('스캔 중: namespace ' + ns + ' (' + index + '/' + namespaceInfo.ids.length + ')');
                    return fetchAllPagesInNamespace(api, ns).then(function (pages) {
                        pages.forEach(function (page) {
                            var ext = getTitleExtension(page.title);
                            var source;
                            if (!shouldExportSourcePage(page, namespaceInfo)) return;
                            source = {
                                title: page.title,
                                ns: page.ns,
                                namespaceName: namespaceInfo.namesById[page.ns] || getTitleNamespaceName(page.title),
                                ctype: contentTypeForTitle(page.title),
                                kind: isFileSourcePage(page, ext) ? 'file' : 'page'
                            };
                            all.push(source);
                        });
                    });
                });
            });

            return chain.then(function () {
                SOURCE_SEED_TITLES.forEach(function (title) {
                    all.push(sourceFromTitle(title, namespaceInfo));
                });
                return uniqueSources(all);
            });
        });
    }

    function mapLimit(items, limit, iterator) {
        var index = 0;
        var active = 0;
        var done = 0;
        var results = [];

        return new Promise(function (resolve) {
            function launch() {
                var current;
                while (active < limit && index < items.length) {
                    current = index;
                    index += 1;
                    active += 1;
                    Promise.resolve(iterator(items[current], current)).then(function (result) {
                        results[current] = result;
                    }).catch(function (err) {
                        results[current] = { error: err };
                    }).finally(function () {
                        active -= 1;
                        done += 1;
                        if (done >= items.length) {
                            resolve(results);
                        } else {
                            launch();
                        }
                    });
                }
                if (!items.length) resolve(results);
            }
            launch();
        });
    }
    function downloadBlob(blob, filename) {
        var url = URL.createObjectURL(blob);
        var a = document.createElement('a');
        a.href = url;
        a.download = filename;
        document.body.appendChild(a);
        a.click();
        a.remove();
        setTimeout(function () {
            URL.revokeObjectURL(url);
        }, 1000);
    }


    function exportSources() {
        setButtonsDisabled(true);
        setStatus('위키 소스 스캔 준비 중...');

        loadScript(JSZIP_URL)
            .then(function () {
                return new Promise(function (resolve, reject) {
                    withApi(function (api) {
                        resolve(api);
                    }, function () {
                        reject(new Error('mediawiki.api 로드 실패'));
                    });
                });
            })
            .then(function (api) {
                var zip = new JSZip();
                var meta = [];
                var errors = [];

                return scanSourcePages(api, setStatus).then(function (sources) {
                    setStatus('소스 수집 중: 0/' + sources.length);
                    return mapLimit(sources, 6, function (file, index) {
                        var fetcher = file.kind === 'file' ? fetchFileBlob(api, file) : fetchRaw(file);
                        return fetcher.then(function (payload) {
                            zip.file(file.out, payload);
                            meta.push({
                                title: file.title,
                                out: file.out,
                                kind: file.kind,
                                ns: file.ns,
                                ctype: file.ctype
                            });
                            if ((index + 1) % 5 === 0 || index + 1 === sources.length) {
                                setStatus('소스 수집 중: ' + (index + 1) + '/' + sources.length);
                            }
                        }).catch(function (err) {
                            var message = String(err && err.message ? err.message : err);
                            var errorPath = '_errors/' + sanitizeTitlePath(file.title) + '.ERROR.txt';
                            zip.file(errorPath, message);
                            errors.push({
                                title: file.title,
                                out: file.out,
                                error: message
                            });
                        });
                    }).then(function () {
                        var sortedFiles = meta.slice().sort(function (a, b) { return a.out.localeCompare(b.out); });
                        var readme = 'CLBI Wiki source export\n' +
                            'Generated: ' + new Date().toISOString() + '\n' +
                            'Mode: whole-wiki source scan\n' +
                            'Included: ' + sortedFiles.length + '\n' +
                            'Errors: ' + errors.length + '\n\n' +
                            'Scope:\n' +
                            '- CSS / JS pages in any namespace\n' +
                            '- JSON / GeoJSON / TopoJSON and other source files, excluding SVG\n' +
                            '- MediaWiki / Template / Module / Manage namespace source pages\n' +
                            '- Source-like files in File namespace, including GeoJSON/GPKG/shapefile sidecars when present\n' +
                            '- Seeded known CLBI source pages are added even if namespace scan misses them\n\n' +
                            'Files:\n' + sortedFiles.map(function (item) {
                                return item.out + ' <= ' + item.title;
                            }).join('\n') + '\n';

                        zip.file('README.txt', readme);
                        zip.file('manifest.json', JSON.stringify({
                            generated: new Date().toISOString(),
                            mode: 'whole-wiki-source-scan',
                            included: sortedFiles.length,
                            errors: errors,
                            files: sortedFiles
                        }, null, 2) + '\n');

                        setStatus('ZIP 압축 중...');
                        return zip.generateAsync({ type: 'blob' });
                    });
                });
            })
            .then(function (blob) {
                var stamp = new Date().toISOString().replace(/[:.]/g, '-');
                downloadBlob(blob, 'clbiwiki-source-' + stamp + '.zip');
                setStatus('ZIP 다운로드 완료', 'ok');
            })
            .catch(function (err) {
                setStatus('ZIP 실패: ' + (err && err.message ? err.message : err), 'error');
            })
            .finally(function () {
                setButtonsDisabled(false);
            });
    }


    function withApi(done, fail) {
        mw.loader.using(['mediawiki.api']).then(function () {
            done(new mw.Api());
        }, function () {
            if (typeof fail === 'function') fail();
        });
    }

    function updateReadout(summary) {
        var $box = $('#dev-tools-progress-readout');
        if (!summary) {
            $box.text('NO DATA');
            return;
        }

        $box.html(
            '<span>' + (summary.isMaxLevel ? 'MAX ' : 'LVL ') + escapeHtml(summary.level) + '</span>' +
            '<span>' + escapeHtml(summary.totalXp) + ' XP</span>' +
            '<span>TODAY ' + escapeHtml(summary.dailyXp) + ' XP</span>' +
            '<span>READ ' + escapeHtml(summary.dailyReadCount) + '</span>' +
            '<span>DISC ' + escapeHtml(summary.discoveryCount) + '</span>'
        );
    }

    function refreshProgressSummary() {
        withApi(function (api) {
            api.get({
                action: 'progress_summary',
                format: 'json',
                formatversion: 2
            }).then(function (data) {
                var payload = data && data.progress_summary;
                if (payload && payload.available && payload.summary) {
                    updateReadout(payload.summary);
                    if (window.ProgressSystemWebUi && typeof window.ProgressSystemWebUi.requestSummary === 'function') {
                        window.ProgressSystemWebUi.requestSummary(true);
                    }
                    setStatus('진행도 동기화 완료', 'ok');
                } else {
                    updateReadout(null);
                    setStatus('진행도 없음', 'error');
                }
            }).catch(function () {
                setStatus('동기화 실패', 'error');
            });
        }, function () {
            setStatus('mediawiki.api 로드 실패', 'error');
        });
    }

    function runProgressAdmin(operation, extra) {
        extra = extra || {};
        setButtonsDisabled(true);
        setStatus('요청 처리 중...');

        withApi(function (api) {
            var payload = $.extend({
                action: 'progress_admin',
                format: 'json',
                formatversion: 2,
                errorformat: 'plaintext',
                operation: operation
            }, extra);

            api.postWithToken('csrf', payload).then(function (data) {
                var result = data && data.progress_admin;
                if (!result || !result.ok) {
                    setStatus('실패: ' + (result && result.reason ? result.reason : 'unknown'), 'error');
                    return;
                }

                if (result.summary) {
                    updateReadout(result.summary);

                    if (window.ProgressSystemWebUi && typeof window.ProgressSystemWebUi.applySummary === 'function') {
                        window.ProgressSystemWebUi.applySummary(result.summary, {
                            animateGain: operation === 'add_xp'
                        });
                    } else if (window.ProgressSystemWebUi && typeof window.ProgressSystemWebUi.requestSummary === 'function') {
                        window.ProgressSystemWebUi.requestSummary();
                    }
                }

                setStatus('완료: ' + operation, 'ok');
            }).catch(function (code) {
                setStatus('API 실패: ' + code, 'error');
            }).always(function () {
                setButtonsDisabled(false);
            });
        }, function () {
            setButtonsDisabled(false);
            setStatus('mediawiki.api 로드 실패', 'error');
        });
    }


    function normalizePageName(value) {
        return String(value || '')
            .split('?')[0]
            .replace(/^\/index\.php\//, '')
            .replace(/_/g, ' ')
            .trim();
    }

    function getLanguageStatusPageKey() {
        var raw = String(mw.config.get('wgPageName') || '').trim();
        var normalized = normalizePageName(raw);
        return normalized || raw || '대문';
    }

    function normalizeLanguageStatusValue(value) {
        value = String(value == null ? '' : value).toLowerCase().trim();
        return LANGUAGE_STATUS_VALUES[value] ? value : '';
    }

    function getLanguageTargetTitle(lang) {
        var data = document.getElementById('clbi-lang-data');
        var keys;
        var i;
        var value;

        if (!data || !lang) return '';

        keys = [
            'data-' + lang,
            'data-page-' + lang,
            'data-title-' + lang,
            'data-target-' + lang,
            'data-lang-' + lang
        ];

        for (i = 0; i < keys.length; i += 1) {
            value = data.getAttribute(keys[i]);
            if (value) return value;
        }

        return '';
    }

    function getLanguageStatusPages(registry) {
        if (!registry || typeof registry !== 'object') return {};
        if (registry.pages && typeof registry.pages === 'object') return registry.pages;
        return registry;
    }

    function readLanguageStatusEntry(registry, pageKey) {
        var pages = getLanguageStatusPages(registry);
        var underscored = String(pageKey || '').replace(/ /g, '_');

        if (pages[pageKey] && typeof pages[pageKey] === 'object') return pages[pageKey];
        if (pages[underscored] && typeof pages[underscored] === 'object') return pages[underscored];

        return {};
    }

    function fetchLanguageStatusRegistry(api) {
        return api.get({
            action: 'query',
            prop: 'revisions',
            titles: LANGUAGE_STATUS_TITLE,
            rvprop: 'content',
            rvslots: 'main',
            format: 'json',
            formatversion: 2
        }).then(function (data) {
            var pages = data && data.query ? data.query.pages : [];
            var page = pages && pages.length ? pages[0] : null;
            var text = '';

            if (!page || page.missing) return {};

            if (page.revisions && page.revisions[0]) {
                if (page.revisions[0].slots && page.revisions[0].slots.main) {
                    text = page.revisions[0].slots.main.content || '';
                } else {
                    text = page.revisions[0].content || '';
                }
            }

            if (!text) return {};

            try {
                return JSON.parse(text);
            } catch (err) {
                setStatus('LanguageStatus JSON 파싱 실패', 'error');
                return {};
            }
        });
    }

    function renderLanguageStatusEditor(registry) {
        var pageKey = getLanguageStatusPageKey();
        var entry = readLanguageStatusEntry(registry, pageKey);
        var currentLang = (document.getElementById('clbi-lang-data') || {}).getAttribute
            ? (document.getElementById('clbi-lang-data').getAttribute('data-lang') || 'ko')
            : 'ko';
        var html = '';

        $('#dev-tools-language-page').text('PAGE: ' + pageKey);

        LANGUAGE_ORDER.forEach(function (lang) {
            var stored = normalizeLanguageStatusValue(entry[lang]);
            var fallback = getLanguageTargetTitle(lang) ? 'available' : 'unavailable';
            var value = stored || fallback;
            var isCurrent = lang === currentLang;

            html += '' +
                '<div class="dev-tools-language-row" data-lang="' + escapeHtml(lang) + '">' +
                    '<span class="dev-tools-language-name">' + escapeHtml(LANGUAGE_LABELS[lang] || lang.toUpperCase()) + (isCurrent ? ' *' : '') + '</span>' +
                    '<select class="dev-tools-select dev-tools-language-select" data-lang="' + escapeHtml(lang) + '">' +
                        '<option value="available"' + (value === 'available' ? ' selected' : '') + '>AVAILABLE</option>' +
                        '<option value="wip"' + (value === 'wip' ? ' selected' : '') + '>WIP</option>' +
                        '<option value="unavailable"' + (value === 'unavailable' ? ' selected' : '') + '>UNAVAILABLE</option>' +
                    '</select>' +
                '</div>';
        });

        $('#dev-tools-language-list').html(html || 'NO DATA');
    }

    function refreshLanguageStatusEditor() {
        withApi(function (api) {
            setStatus('언어 상태 불러오는 중...');
            fetchLanguageStatusRegistry(api).then(function (registry) {
                renderLanguageStatusEditor(registry || {});
                setStatus('언어 상태 로드 완료', 'ok');
            }).catch(function () {
                renderLanguageStatusEditor({});
                setStatus('언어 상태 로드 실패', 'error');
            });
        }, function () {
            setStatus('mediawiki.api 로드 실패', 'error');
        });
    }

    function saveLanguageStatusEditor() {
        withApi(function (api) {
            var pageKey = getLanguageStatusPageKey();

            setButtonsDisabled(true);
            setStatus('언어 상태 저장 중...');

            fetchLanguageStatusRegistry(api).then(function (registry) {
                var pages = getLanguageStatusPages(registry);
                var nextRegistry = registry && typeof registry === 'object' ? registry : {};
                var entry = {};

                if (nextRegistry.pages && typeof nextRegistry.pages === 'object') {
                    pages = nextRegistry.pages;
                } else {
                    pages = nextRegistry;
                }

                $('.dev-tools-language-select').each(function () {
                    var lang = $(this).data('lang');
                    var value = normalizeLanguageStatusValue($(this).val());

                    if (lang && value) {
                        entry[lang] = value;
                    }
                });

                pages[pageKey] = entry;

                return api.postWithToken('csrf', {
                    action: 'edit',
                    title: LANGUAGE_STATUS_TITLE,
                    text: JSON.stringify(nextRegistry, null, 2) + '\n',
                    summary: 'Update language status: ' + pageKey,
                    contentmodel: 'json',
                    format: 'json',
                    formatversion: 2
                });
            }).then(function () {
                setStatus('언어 상태 저장 완료', 'ok');

                if (window.CLBI_LANGUAGE_STATUS && typeof window.CLBI_LANGUAGE_STATUS.reload === 'function') {
                    window.CLBI_LANGUAGE_STATUS.reload();
                }
            }).catch(function (err) {
                setStatus('언어 상태 저장 실패: ' + (err && err.message ? err.message : err), 'error');
            }).always(function () {
                setButtonsDisabled(false);
            });
        }, function () {
            setButtonsDisabled(false);
            setStatus('mediawiki.api 로드 실패', 'error');
        });
    }

    function toggleRenameBox() {
        var box = document.getElementById('dev-tools-rename-box');
        var targetInput;

        if (!box) return;

        box.hidden = !box.hidden;

        if (!box.hidden) {
            targetInput = document.getElementById('dev-tools-rename-to');
            if (targetInput) targetInput.focus();
        }
    }

    function resolveRenameRedirectTarget(api, fromTitle) {
        return api.get({
            action: 'query',
            titles: normalizeApiTitle(fromTitle),
            redirects: 1,
            prop: 'info',
            format: 'json',
            formatversion: 2
        }).then(function (data) {
            var redirects = data && data.query ? data.query.redirects : [];
            var item = redirects && redirects.length ? redirects[0] : null;

            if (item && item.to) return item.to;
            return '';
        }).catch(function () {
            return '';
        });
    }

    function runRenameReferenceCleanupOnly() {
        var fromTitle = normalizeMoveTitle($('#dev-tools-rename-from').val());
        var toTitle = normalizeMoveTargetTitle(fromTitle, $('#dev-tools-rename-to').val());

        if (!fromTitle) {
            setStatus('현재 이름이 비어 있습니다.', 'error');
            return;
        }

        setButtonsDisabled(true);
        setStatus('참조/리스트 정리 준비 중...');

        withApi(function (api) {
            var targetPromise = toTitle ? Promise.resolve(toTitle) : resolveRenameRedirectTarget(api, fromTitle);

            targetPromise.then(function (resolvedToTitle) {
                resolvedToTitle = normalizeMoveTitle(resolvedToTitle);

                if (!resolvedToTitle) {
                    throw new Error('새 이름을 입력하거나, 현재 이름이 새 이름으로 넘겨주는 redirect여야 합니다.');
                }

                if (normalizeMoveCompare(fromTitle) === normalizeMoveCompare(resolvedToTitle)) {
                    throw new Error('현재 이름과 새 이름이 같습니다.');
                }

                setStatus('참조/리스트 정리 중...');
                return updateRenameReferences(api, fromTitle, resolvedToTitle).then(function (referenceResult) {
                    return { movedTo: resolvedToTitle, referenceResult: referenceResult };
                });
            }).then(function (result) {
                var referenceResult = result.referenceResult || { changed: 0, failed: 0, scanned: 0 };
                var message = '참조/리스트 정리 완료: ' + fromTitle + ' → ' + result.movedTo +
                    ' / 변경 ' + referenceResult.changed + '개 / 검사 ' + referenceResult.scanned + '개';

                if (referenceResult.failed) message += ' / 실패 ' + referenceResult.failed + '개';
                setStatus(message, referenceResult.failed ? 'error' : 'ok');
                setButtonsDisabled(false);
                $('#dev-tools-rename-to').val(result.movedTo);
            }).catch(function (err) {
                setButtonsDisabled(false);
                setStatus('참조/리스트 정리 실패: ' + (err && err.message ? err.message : err), 'error');
            });
        }, function () {
            setButtonsDisabled(false);
            setStatus('mediawiki.api 로드 실패', 'error');
        });
    }

    function runDocumentRename() {
        var fromTitle = normalizeMoveTitle($('#dev-tools-rename-from').val());
        var toTitle = normalizeMoveTargetTitle(fromTitle, $('#dev-tools-rename-to').val());
        var reason = String($('#dev-tools-rename-reason').val() || '이름 변경').trim();
        var keepLegacy = $('#dev-tools-rename-legacy').prop('checked');
        var moveTalk = $('#dev-tools-rename-talk').prop('checked');
        var moveSubpages = $('#dev-tools-rename-subpages').prop('checked');
        var shouldUpdateReferences = $('#dev-tools-rename-update-references').prop('checked');
        var payload;

        if (!fromTitle) {
            setStatus('현재 이름이 비어 있습니다.', 'error');
            return;
        }

        if (!toTitle) {
            setStatus('새 이름이 비어 있습니다.', 'error');
            return;
        }

        if (normalizeMoveCompare(fromTitle) === normalizeMoveCompare(toTitle)) {
            setStatus('현재 이름과 새 이름이 같습니다.', 'error');
            return;
        }

        if (!window.confirm('실제 이름을 변경할까요?\n\n' + fromTitle + '\n→ ' + toTitle + (keepLegacy ? '\n\n기존 이름은 레거시 넘겨주기로 유지됩니다.' : '\n\n기존 이름 레거시는 남기지 않습니다.'))) {
            return;
        }

        payload = {
            action: 'move',
            from: fromTitle,
            to: toTitle,
            reason: reason,
            format: 'json',
            formatversion: 2,
            errorformat: 'plaintext'
        };

        if (moveTalk) payload.movetalk = 1;
        if (moveSubpages) payload.movesubpages = 1;
        if (!keepLegacy) payload.noredirect = 1;

        setButtonsDisabled(true);
        setStatus('실제 이름 변경 중...');

        withApi(function (api) {
            api.postWithToken('csrf', payload).then(function (data) {
                var moved = data && data.move;
                var movedTo = moved && (moved.to || toTitle);

                if (!shouldUpdateReferences) {
                    return { movedTo: movedTo, referenceResult: null };
                }

                return updateRenameReferences(api, fromTitle, movedTo || toTitle).then(function (referenceResult) {
                    return { movedTo: movedTo, referenceResult: referenceResult };
                });
            }).then(function (result) {
                var movedTo = result && result.movedTo ? result.movedTo : toTitle;
                var referenceResult = result && result.referenceResult;
                var message = '이름 변경 완료: ' + movedTo;

                if (referenceResult) {
                    message += ' / 참조 ' + referenceResult.changed + '개 정리';
                    if (referenceResult.failed) message += ' / 실패 ' + referenceResult.failed + '개';
                }

                setStatus(message, referenceResult && referenceResult.failed ? 'error' : 'ok');

                if (isCurrentMoveSource(fromTitle)) {
                    window.setTimeout(function () {
                        window.location.href = getDocumentToolUrl(movedTo);
                    }, 700);
                } else {
                    setButtonsDisabled(false);
                    $('#dev-tools-rename-from').val(movedTo);
                    $('#dev-tools-rename-to').val('');
                }
            }).catch(function (code, details) {
                var message = code;

                if (details && details.error && details.error.info) {
                    message = details.error.info;
                } else if (details && details.info) {
                    message = details.info;
                } else if (code && code.message) {
                    message = code.message;
                }

                setButtonsDisabled(false);
                setStatus('이름 변경 실패: ' + message, 'error');
            });
        }, function () {
            setButtonsDisabled(false);
            setStatus('mediawiki.api 로드 실패', 'error');
        });
    }


    function bindEvents() {
        $(document).on('click', '#dev-tools-panel .dev-tools-button-disabled', function (event) {
            event.preventDefault();
            event.stopPropagation();
        });

        $(document).on('click', '#dev-tools-toggle', function () {
            var $panel = $('#dev-tools-panel');
            $panel.toggleClass('dev-tools-collapsed');
            var collapsed = $panel.hasClass('dev-tools-collapsed');
            localStorage.setItem('dev-tools-collapsed', collapsed ? 'true' : 'false');
            $(this).text(collapsed ? '+' : '−');
        });

        $(document).on('click', '#dev-tools-export', exportSources);
        $(document).on('click', '#dev-tools-rename-toggle', toggleRenameBox);
        $(document).on('click', '#dev-tools-rename-submit', runDocumentRename);
        $(document).on('click', '#dev-tools-rename-cleanup-submit', runRenameReferenceCleanupOnly);
        $(document).on('keydown', '#dev-tools-rename-to', function (event) {
            if (event.key === 'Enter') runDocumentRename();
        });
        $(document).on('click', '#dev-tools-progress-refresh', refreshProgressSummary);
        $(document).on('click', '#dev-tools-language-refresh', refreshLanguageStatusEditor);
        $(document).on('click', '#dev-tools-language-save', saveLanguageStatusEditor);

        $(document).on('click', '#dev-tools-xp-add', function () {
            var amount = parseInt($('#dev-tools-xp-amount').val(), 10);
            var reason = $('#dev-tools-xp-reason').val() || '관리자 테스트';

            if (!Number.isFinite(amount) || amount <= 0) {
                setStatus('XP 값이 잘못되었습니다.', 'error');
                return;
            }

            runProgressAdmin('add_xp', {
                amount: amount,
                reason: reason
            });
        });

        $(document).on('click', '#dev-tools-daily-reset', function () {
            runProgressAdmin('reset_daily');
        });

        $(document).on('click', '#dev-tools-level-reset', function () {
            if (!window.confirm('레벨/총 경험치와 오늘 보너스 카운트를 리셋할까요?')) return;
            runProgressAdmin('reset_progress');
        });
    }

    $(function () {
        buildPanel();
        bindEvents();

        if (isOwner()) {
            refreshProgressSummary();
            refreshLanguageStatusEditor();
        }

        if (mw.hook) {
            mw.hook('wikipage.content').add(refreshDocumentTools);
        }
    });
})(mediaWiki, jQuery);