미디어위키:Nation List Manager.js

Nxdsxn (토론 | 기여)님의 2026년 7월 5일 (일) 08:44 판

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

  • 파이어폭스 / 사파리: Shift 키를 누르면서 새로 고침을 클릭하거나, Ctrl-F5 또는 Ctrl-R을 입력 (Mac에서는 ⌘-R)
  • 구글 크롬: Ctrl-Shift-R키를 입력 (Mac에서는 ⌘-Shift-R)
  • 인터넷 익스플로러 / 엣지: Ctrl 키를 누르면서 새로 고침을 클릭하거나, Ctrl-F5를 입력.
  • 오페라: Ctrl-F5를 입력.
(function () {
    'use strict';

    var DEFAULT_YEARS = [
        '1950', '1960', '1970', '1980', '1990', '2000', '2010', '2020',
        '2030', '2040', '2050', '2060', '2070', '2080', '2090', '2100'
    ];

    var CONTINENT_TEMPLATE = [
        { key: 'america', label: '아메리카', regions: ['북아메리카', '남아메리카'] },
        { key: 'europe', label: '유럽', regions: ['북유럽', '서유럽', '중앙유럽', '동유럽', '동남유럽', '남유럽', '남서유럽'] },
        { key: 'africa', label: '아프리카', regions: ['북아프리카', '서아프리카', '중앙아프리카', '동아프리카', '남아프리카'] },
        { key: 'asia', label: '아시아', regions: ['서아시아', '중앙아시아', '북아시아', '남아시아', '동아시아', '동남아시아'] },
        { key: 'oceania', label: '오세아니아', regions: ['오세아니아'] },
        { key: 'antarctica', label: '남극', regions: ['남극'] }
    ];

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

    function encodeWikiTitle(title) {
        return String(title || '')
            .trim()
            .replace(/ /g, '_')
            .split('/')
            .map(function (part) { return encodeURIComponent(part); })
            .join('/');
    }

    function replaceYearTemplate(template, year) {
        return String(template || '').replace(/\{year\}/g, String(year || ''));
    }

    function normalizeFileTitle(value) {
        return String(value || '')
            .trim()
            .replace(/^\[\[:?\s*/i, '')
            .replace(/\]\]$/i, '')
            .replace(/^파일\s*:/i, '')
            .replace(/^File\s*:/i, '')
            .replace(/^이미지\s*:/i, '')
            .replace(/^Image\s*:/i, '')
            .replace(/^:+/, '')
            .trim();
    }

    function buildFileRedirectUrl(value) {
        var file = normalizeFileTitle(value);
        return file ? '/index.php/Special:Redirect/file/' + encodeWikiTitle(file) : '';
    }

    function fileDisplayTitle(value) {
        var file = normalizeFileTitle(value);
        return file ? '파일:' + file : '';
    }

    function getWikiSortBucket(value) {
        var text = String(value || '').trim();
        var first = text.charAt(0);

        if (/^[A-Za-z0-9]/.test(first)) return 0;
        if (/^[가-힣ㄱ-ㅎㅏ-ㅣ]/.test(first)) return 1;
        return 2;
    }

    function compareWikiLabels(a, b) {
        var av = String(a == null ? '' : a);
        var bv = String(b == null ? '' : b);
        var ab = getWikiSortBucket(av);
        var bb = getWikiSortBucket(bv);

        if (ab !== bb) return ab - bb;

        return av.localeCompare(bv, ['en', 'ko'], { numeric: true, sensitivity: 'base' });
    }

    function normalizeTagInput(value) {
        return String(value || '')
            .trim()
            .toUpperCase()
            .replace(/\s+/g, '')
            .replace(/[^A-Z0-9]/g, '')
            .slice(0, 3);
    }

    function isValidTag(value) {
        return /^[A-Z0-9]{3}$/.test(normalizeTagInput(value));
    }

    function getItemTag(item) {
        var tag = normalizeTagInput(item && (item.tag || item.code || item.map_tag || item.mapTag || ''));
        return isValidTag(tag) ? tag : '';
    }

    function getEntryTag(entry, fallback) {
        var tag = normalizeTagInput(entry && (entry.tag || entry.code || entry.page || '') || fallback || '');
        return isValidTag(tag) ? tag : '';
    }

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

    function collapseDuplicateFileExtension(value) {
        var file = normalizeFileTitle(value);
        return file.replace(/(\.(?:svg|png|jpe?g|gif|webp|avif|ico|pdf|geojson|json))\1$/i, '$1');
    }

    function cleanupFlagFileSearchList(list) {
        var fileSet = {};
        var seen = {};
        var cleaned = [];

        (list || []).forEach(function (item) {
            var key = normalizeFileKey(item && item.file);
            if (key) fileSet[key] = true;
        });

        (list || []).forEach(function (item) {
            var file = normalizeFileTitle(item && item.file);
            var key = normalizeFileKey(file);
            var collapsed = collapseDuplicateFileExtension(file);
            var collapsedKey = normalizeFileKey(collapsed);

            if (!file || !key) return;
            if (item && item.isRedirect) return;
            if (collapsedKey && collapsedKey !== key && fileSet[collapsedKey]) return;
            if (seen[key]) return;

            seen[key] = true;
            cleaned.push(item);
        });

        return cleaned.sort(function (a, b) {
            return compareWikiLabels(a.file, b.file);
        });
    }

    function clampNumber(value, min, max) {
        value = parseFloat(value);
        if (!isFinite(value)) value = 0;
        return Math.max(min, Math.min(max, value));
    }

    function normalizeOffsetMode(value) {
        value = String(value == null ? '' : value).toLowerCase().trim();
        return value === 'manual' ? 'manual' : 'auto';
    }

    function getFlagOffsetY(flag) {
        var value;

        if (!flag || typeof flag !== 'object') return 0;

        value = flag.offsetY;
        if (value == null) value = flag.offset_y;
        if (value == null) value = flag.flagOffsetY;

        if (value == null && flag.decorator === 'top') value = -1;

        return Math.round(clampNumber(value, -3, 3));
    }

    function getFlagOffsetMode(flag) {
        if (!flag || typeof flag !== 'object') return 'auto';
        if (flag.offsetMode || flag.offset_mode) return normalizeOffsetMode(flag.offsetMode || flag.offset_mode);
        if (flag.decorator === 'top') return 'manual';
        return 'auto';
    }

    function normalizeRegionKey(label) {
        return String(label || '')
            .trim()
            .replace(/\s+/g, '_')
            .replace(/[^A-Za-z0-9가-힣_\-]/g, '') || 'region';
    }

    function makeEmptyState(year) {
        return {
            version: 1,
            year: parseInt(year || '1950', 10),
            description: '국가 및 조합 ' + year + '년 당대 존재 국가 패널 데이터. Manage:Nation_List에서 편집한다.',
            continents: CONTINENT_TEMPLATE.map(function (continent) {
                return {
                    key: continent.key,
                    label: continent.label,
                    regions: continent.regions.map(function (region) {
                        return {
                            key: normalizeRegionKey(region),
                            label: region,
                            items: []
                        };
                    })
                };
            })
        };
    }

    function normalizeFlag(flag) {
        var normalized;

        if (!flag || typeof flag !== 'object') flag = {};

        normalized = {
            file: normalizeFileTitle(flag.file || flag.flag_file || flag.flag || ''),
            width: String(flag.width || flag.flag_width || '16px').trim() || '16px',
            border: flag.border !== false,
            offsetMode: getFlagOffsetMode(flag),
            offsetY: getFlagOffsetY(flag)
        };

        return normalized;
    }

    function normalizeItem(item) {
        var flags;

        item = item && typeof item === 'object' ? item : {};
        flags = Array.isArray(item.flags) ? item.flags.map(normalizeFlag) : [];

        if (!flags.length && (item.flag_file || item.flag)) {
            flags.push(normalizeFlag({ file: item.flag_file || item.flag, width: item.flag_width || '16px', border: item.border !== false, decorator: item.decorator || '' }));
        }

        return {
            tag: normalizeTagInput(item.tag || item.code || item.map_tag || item.mapTag || ''),
            page: String(item.page || item.wiki_title || '').trim(),
            label: String(item.label || item.name || item.name_ko || item.page || '').trim(),
            external_url: String(item.external_url || item.url || '').trim(),
            flags: flags,
            note: String(item.note || '').trim(),
            enabled: item.enabled !== false
        };
    }

    function normalizeState(payload, year) {
        var state = makeEmptyState(year);
        var byKey = {};

        if (!payload || typeof payload !== 'object') return state;

        if (Array.isArray(payload.continents)) {
            payload.continents.forEach(function (continent) {
                if (continent && continent.key) byKey[continent.key] = continent;
            });
        }

        state.version = payload.version || 1;
        state.year = payload.year || state.year;
        state.description = payload.description || state.description;
        state.continents = state.continents.map(function (baseContinent) {
            var source = byKey[baseContinent.key] || {};
            var regionsByKey = {};

            if (Array.isArray(source.regions)) {
                source.regions.forEach(function (region) {
                    if (!region) return;
                    if (region.key) regionsByKey[region.key] = region;
                    if (region.label) regionsByKey[normalizeRegionKey(region.label)] = region;
                });
            }

            return {
                key: baseContinent.key,
                label: source.label || baseContinent.label,
                regions: baseContinent.regions.map(function (baseRegion) {
                    var region = regionsByKey[baseRegion.key] || regionsByKey[normalizeRegionKey(baseRegion.label)] || {};
                    return {
                        key: region.key || baseRegion.key,
                        label: region.label || baseRegion.label,
                        items: Array.isArray(region.items) ? region.items.map(normalizeItem) : []
                    };
                })
            };
        });

        return state;
    }

    function getItemPrimaryFlag(item) {
        if (!item.flags || !item.flags.length) item.flags = [normalizeFlag({})];
        return item.flags[0];
    }

    function normalizeParentPage(value, page) {
        var parent = String(value || '').trim().replace(/\s+/g, ' ');
        var current = String(page || '').trim();

        if (!parent || parent === current) return '';
        return parent;
    }

    function normalizeRelation(value) {
        return String(value || '').trim().replace(/\s+/g, ' ');
    }

    function normalizeLinkMap(payload, year) {
        var source = payload && payload.items ? payload.items : {};
        var items = {};

        Object.keys(source).sort(compareWikiLabels).forEach(function (key) {
            var entry = source[key] || {};
            var rawPage = String(entry.page || key || '').trim();
            var tag = getEntryTag(entry, key);
            var mapKey = tag || rawPage;

            if (!mapKey) return;

            items[mapKey] = {
                tag: tag || normalizeTagInput(entry.tag || entry.code || key || ''),
                page: tag || rawPage,
                name_ko: String(entry.name_ko || entry.name || '').trim(),
                wiki_title: String(entry.wiki_title || entry.title || '').trim(),
                flag_file: normalizeFileTitle(entry.flag_file || entry.flag || entry.flag_title || ''),
                hover_parent: normalizeTagInput(
                    entry.hover_parent || entry.hoverParent || entry.parent_page || entry.parentPage || entry.parent ||
                    ((entry.hover_group || entry.hoverGroup || entry.group || '') && (entry.hover_group || entry.hoverGroup || entry.group || '') !== rawPage ? (entry.hover_group || entry.hoverGroup || entry.group || '') : '')
                ),
                hover_relation: normalizeRelation(entry.hover_relation || entry.hoverRelation || entry.relation || ''),
                enabled: entry.enabled !== false,
                color: entry.color || '',
                note: String(entry.note || '').trim()
            };
        });

        return {
            version: payload && payload.version ? payload.version : 1,
            year: payload && payload.year ? payload.year : parseInt(year || '1950', 10),
            description: payload && payload.description ? payload.description : (year || '1950') + ' nation hover/click link map.',
            relation_options: payload && (payload.relation_options || payload.relationOptions || payload.relations) || [],
            items: items
        };
    }

    function getLinkMapTags(map) {
        var tags = {};
        var items = map && map.items ? map.items : {};

        Object.keys(items).forEach(function (key) {
            var tag = getEntryTag(items[key], key);
            if (tag) tags[tag] = true;
        });

        return tags;
    }

    function NationListManager(root) {
        this.root = root;
        this.years = String(root.getAttribute('data-years') || DEFAULT_YEARS.join(' ')).split(/[\s,|]+/).filter(Boolean);
        this.year = root.getAttribute('data-year') || this.years[0] || '1950';
        this.titleTemplate = root.getAttribute('data-list-title-template') || 'MediaWiki:{year}_Nation_List.json';
        this.urlTemplate = root.getAttribute('data-list-url-template') || '/index.php?title=MediaWiki:{year}_Nation_List.json&action=raw&ctype=application/json';
        this.mapTitleTemplate = root.getAttribute('data-map-title-template') || 'MediaWiki:{year}_Nation_Link_Map.json';
        this.mapUrlTemplate = root.getAttribute('data-map-url-template') || '/index.php?title=MediaWiki:{year}_Nation_Link_Map.json&action=raw&ctype=application/json';
        this.baseUrl = root.getAttribute('data-link-base') || '/index.php/';
        this.state = null;
        this.linkMap = null;
        this.linkMapExists = false;
        this.linkMapDirty = false;
        this.mapTags = null;
        this.activeContinent = 'america';
        this.query = '';
        this.status = '';
        this.sourceExists = false;
        this.loading = false;
        this.dragSource = null;
        this.flagSearchKeyword = root.getAttribute('data-flag-search-keyword') || 'flag';
        this.flagSearchLimit = Math.max(10, Math.min(200, parseInt(root.getAttribute('data-flag-search-limit') || '80', 10) || 80));
        this.flagFiles = null;
        this.flagFilesLoading = false;
        this.flagPicker = null;
        this.activeFlagInput = null;
    }

    NationListManager.prototype.getListTitle = function () {
        return replaceYearTemplate(this.titleTemplate, this.year);
    };

    NationListManager.prototype.getListUrl = function () {
        return replaceYearTemplate(this.urlTemplate, this.year);
    };

    NationListManager.prototype.getMapTitle = function () {
        return replaceYearTemplate(this.mapTitleTemplate, this.year);
    };

    NationListManager.prototype.getMapUrl = function () {
        return replaceYearTemplate(this.mapUrlTemplate, this.year);
    };

    NationListManager.prototype.init = function () {
        this.root.classList.add('nation-list-manager-ready');
        this.loadYear(this.year);
    };

    NationListManager.prototype.loadYear = function (year) {
        var self = this;

        this.year = String(year || this.year || '1950');
        this.loading = true;
        this.status = this.year + '년 데이터 불러오는 중...';
        this.renderShell();

        return fetch(this.getListUrl(), { credentials: 'same-origin', cache: 'no-cache' })
            .then(function (res) {
                if (!res.ok) throw new Error('HTTP ' + res.status);
                return res.text();
            })
            .then(function (text) {
                var trimmed = String(text || '').trim();
                if (!trimmed) throw new Error('empty');
                self.state = normalizeState(JSON.parse(trimmed), self.year);
                self.sourceExists = true;
                self.status = self.year + '년 목록 로드 완료';
            })
            .catch(function () {
                self.state = makeEmptyState(self.year);
                self.sourceExists = false;
                self.status = self.year + '년 목록 문서가 없어 빈 틀로 시작합니다.';
            })
            .then(function () {
                return self.loadMapWarnings();
            })
            .finally(function () {
                self.loading = false;
                self.render();
            });
    };

    NationListManager.prototype.loadMapWarnings = function () {
        var self = this;

        this.mapTags = null;
        this.linkMap = null;
        this.linkMapExists = false;
        this.linkMapDirty = false;

        return fetch(this.getMapUrl(), { credentials: 'same-origin', cache: 'no-cache' })
            .then(function (res) {
                if (!res.ok) throw new Error('HTTP ' + res.status);
                return res.text();
            })
            .then(function (text) {
                var trimmed = String(text || '').trim();
                self.linkMap = trimmed ? normalizeLinkMap(JSON.parse(trimmed), self.year) : normalizeLinkMap({}, self.year);
                self.linkMapExists = true;
                self.inferMissingTagsFromMap();
                self.mapTags = getLinkMapTags(self.linkMap);
            })
            .catch(function () {
                self.linkMap = null;
                self.linkMapExists = false;
                self.linkMapDirty = false;
                self.mapTags = null;
            });
    };

    NationListManager.prototype.loadFlagFiles = function () {
        var self = this;
        var apiUrl;

        if (this.flagFiles) return Promise.resolve(this.flagFiles);
        if (this.flagFilesLoading) {
            return new Promise(function (resolve) {
                var timer = setInterval(function () {
                    if (!self.flagFilesLoading) {
                        clearInterval(timer);
                        resolve(self.flagFiles || []);
                    }
                }, 80);
            });
        }

        this.flagFilesLoading = true;
        apiUrl = (window.mw && mw.util && mw.util.wikiScript) ? mw.util.wikiScript('api') : '/api.php';

        return fetch(apiUrl + '?' + new URLSearchParams({
            action: 'query',
            generator: 'search',
            gsrsearch: this.flagSearchKeyword,
            gsrnamespace: '6',
            gsrlimit: String(this.flagSearchLimit),
            prop: 'info|imageinfo',
            redirects: '1',
            iiprop: 'url|mime',
            iiurlwidth: '32',
            format: 'json'
        }).toString(), { credentials: 'same-origin', cache: 'no-cache' })
            .then(function (res) {
                if (!res.ok) throw new Error('HTTP ' + res.status);
                return res.json();
            })
            .then(function (data) {
                var pages = data && data.query && data.query.pages ? data.query.pages : {};
                var list = Object.keys(pages).map(function (id) {
                    var page = pages[id];
                    var info = page.imageinfo && page.imageinfo[0] ? page.imageinfo[0] : {};
                    var file = normalizeFileTitle(page.title || '');
                    return {
                        title: page.title || fileDisplayTitle(file),
                        file: file,
                        thumb: info.thumburl || info.url || buildFileRedirectUrl(file),
                        url: info.url || buildFileRedirectUrl(file),
                        isRedirect: !!page.redirect
                    };
                }).filter(function (item) {
                    return item.file && item.file.toLowerCase().indexOf(self.flagSearchKeyword.toLowerCase()) !== -1;
                });

                list = cleanupFlagFileSearchList(list);

                self.flagFiles = list;
                return list;
            })
            .catch(function (err) {
                if (window.console && console.warn) console.warn('nation list flag file search failed:', err);
                self.flagFiles = [];
                return [];
            })
            .finally(function () {
                self.flagFilesLoading = false;
            });
    };

    NationListManager.prototype.getActiveContinent = function () {
        var continents = this.state && this.state.continents ? this.state.continents : [];
        var active = continents.find(function (continent) { return continent.key === this.activeContinent; }, this);
        return active || continents[0] || null;
    };

    NationListManager.prototype.getAllItems = function () {
        var rows = [];

        if (!this.state || !Array.isArray(this.state.continents)) return rows;

        this.state.continents.forEach(function (continent) {
            (continent.regions || []).forEach(function (region) {
                (region.items || []).forEach(function (item) {
                    rows.push({ continent: continent, region: region, item: item });
                });
            });
        });

        return rows;
    };

    NationListManager.prototype.findUniqueLinkTagForItem = function (item) {
        var page = String(item && item.page || '').trim();
        var label = String(item && item.label || '').trim();
        var items = this.linkMap && this.linkMap.items ? this.linkMap.items : {};
        var candidates = [];

        if (!page && !label) return '';

        Object.keys(items).forEach(function (key) {
            var entry = items[key] || {};
            var tag = getEntryTag(entry, key);
            var wikiTitle = String(entry.wiki_title || '').trim();
            var nameKo = String(entry.name_ko || '').trim();

            if (!tag) return;
            if ((page && wikiTitle && wikiTitle === page) ||
                    (page && nameKo && nameKo === page) ||
                    (label && wikiTitle && wikiTitle === label) ||
                    (label && nameKo && nameKo === label)) {
                candidates.push(tag);
            }
        });

        candidates = candidates.filter(function (tag, index) {
            return tag && candidates.indexOf(tag) === index;
        });

        return candidates.length === 1 ? candidates[0] : '';
    };

    NationListManager.prototype.inferMissingTagsFromMap = function () {
        var count = 0;

        if (!this.linkMap || !this.linkMap.items || !this.state) return 0;

        this.getAllItems().forEach(function (row) {
            var item = row.item;
            var tag;

            if (!item || getItemTag(item)) return;
            tag = this.findUniqueLinkTagForItem(item);
            if (!tag) return;

            item.tag = tag;
            count += 1;
        }, this);

        return count;
    };

    NationListManager.prototype.validateTags = function () {
        var seen = {};
        var duplicate = [];
        var invalid = [];

        this.getAllItems().forEach(function (row) {
            var item = row.item || {};
            var raw = normalizeTagInput(item.tag || '');
            var name = item.label || item.page || '(이름 없음)';

            if (!raw) return;
            if (!isValidTag(raw)) {
                invalid.push(name + ' = ' + raw);
                return;
            }
            if (seen[raw]) {
                duplicate.push(raw + ' (' + seen[raw] + ' / ' + name + ')');
                return;
            }
            seen[raw] = name;
        });

        return { duplicate: duplicate, invalid: invalid };
    };

    NationListManager.prototype.renameLinkMapTag = function (oldTag, newTag) {
        var map = this.linkMap;
        var entry;

        oldTag = normalizeTagInput(oldTag);
        newTag = normalizeTagInput(newTag);

        if (!map || !map.items || !isValidTag(oldTag) || !isValidTag(newTag) || oldTag === newTag) return false;
        if (!map.items[oldTag] || map.items[newTag]) return false;

        entry = map.items[oldTag];
        delete map.items[oldTag];
        entry.tag = newTag;
        entry.page = newTag;
        if (normalizeTagInput(entry.hover_parent) === oldTag) entry.hover_parent = newTag;

        Object.keys(map.items).forEach(function (key) {
            if (normalizeTagInput(map.items[key] && map.items[key].hover_parent) === oldTag) {
                map.items[key].hover_parent = newTag;
            }
        });

        map.items[newTag] = entry;
        this.linkMapDirty = true;
        this.mapTags = getLinkMapTags(map);
        return true;
    };

    NationListManager.prototype.getListFlagFile = function (item) {
        var flag = item ? getItemPrimaryFlag(item) : null;
        return normalizeFileTitle(flag && flag.file);
    };

    NationListManager.prototype.getLinkEntry = function (item) {
        var tag = getItemTag(item);
        return tag && this.linkMap && this.linkMap.items ? this.linkMap.items[tag] || null : null;
    };

    NationListManager.prototype.getLinkStatus = function (item) {
        var tag = getItemTag(item);
        var listFlag = this.getListFlagFile(item);
        var entry;
        var mapFlag;

        if (item && item.external_url) {
            return { type: 'soft', text: '외부 URL' };
        }

        if (!tag) {
            return { type: 'missing', text: 'TAG 없음' };
        }

        if (!this.linkMap) {
            return { type: 'soft', text: '링크맵 없음' };
        }

        entry = this.getLinkEntry(item);
        if (!entry) {
            return { type: 'missing', text: '링크 없음' };
        }

        mapFlag = normalizeFileTitle(entry.flag_file || '');
        if (listFlag && mapFlag && normalizeFileKey(listFlag) !== normalizeFileKey(mapFlag)) {
            return { type: 'flag-mismatch', text: '국기 불일치', mapFlag: mapFlag, listFlag: listFlag };
        }

        if (!entry.name_ko || !entry.wiki_title) {
            return { type: 'incomplete', text: '입력 필요' };
        }

        return { type: 'ok', text: '연결됨' };
    };

    NationListManager.prototype.renderLinkStatus = function (item) {
        var status = this.getLinkStatus(item);
        var tag = getItemTag(item);
        var title = '';

        if (status.type === 'flag-mismatch') {
            title = 'Nation List: ' + status.listFlag + ' / Link Map: ' + status.mapFlag;
        }

        return '' +
            '<div class="nation-list-link-status is-' + escapeHtml(status.type) + '" title="' + escapeHtml(title) + '">' +
                '<span>' + escapeHtml(status.text) + '</span>' +
                (status.type === 'missing' && tag ? '<button type="button" class="nation-list-link-create">생성</button>' : '') +
                (status.type === 'flag-mismatch' && tag ? '<button type="button" class="nation-list-link-sync-one">국기</button>' : '') +
            '</div>';
    };

    NationListManager.prototype.ensureLinkMap = function () {
        if (!this.linkMap) {
            this.linkMap = normalizeLinkMap({}, this.year);
            this.linkMapExists = false;
        }
        if (!this.linkMap.items) this.linkMap.items = {};
        return this.linkMap;
    };

    NationListManager.prototype.makeLinkEntryFromItem = function (item) {
        var tag = getItemTag(item);
        var page = String(item && item.page || '').trim();
        var label = String(item && item.label || page || '').trim();
        var flag = this.getListFlagFile(item);

        return {
            tag: tag,
            page: tag,
            name_ko: label,
            wiki_title: page,
            flag_file: flag,
            hover_parent: '',
            hover_relation: '',
            enabled: item && item.enabled !== false,
            color: '',
            note: ''
        };
    };

    NationListManager.prototype.createLinkEntryForItem = function (item) {
        var map = this.ensureLinkMap();
        var tag = getItemTag(item);

        if (!tag || item.external_url || map.items[tag]) return false;

        map.items[tag] = this.makeLinkEntryFromItem(item);
        this.linkMapDirty = true;
        this.mapTags = getLinkMapTags(map);
        return true;
    };

    NationListManager.prototype.syncLinkMapFlagForItem = function (item) {
        var entry = this.getLinkEntry(item);
        var flag = this.getListFlagFile(item);

        if (!entry || !flag) return false;
        if (normalizeFileKey(entry.flag_file || '') === normalizeFileKey(flag)) return false;

        entry.flag_file = flag;
        this.linkMapDirty = true;
        return true;
    };

    NationListManager.prototype.createMissingLinkEntries = function () {
        var count = 0;

        this.getAllItems().forEach(function (row) {
            if (row.item && row.item.enabled === false) return;
            if (this.createLinkEntryForItem(row.item)) count += 1;
        }, this);

        this.setStatus(count ? '링크맵 누락 항목 ' + count + '개 생성됨. 저장을 누르면 목록과 링크맵이 함께 저장됩니다.' : '생성할 링크맵 누락 항목이 없습니다.');
        this.render();
    };

    NationListManager.prototype.syncAllLinkMapFlags = function () {
        var count = 0;

        this.getAllItems().forEach(function (row) {
            if (this.syncLinkMapFlagForItem(row.item)) count += 1;
        }, this);

        this.setStatus(count ? 'Nation List 국기 기준으로 링크맵 국기 ' + count + '개 동기화됨.' : '동기화할 국기 불일치가 없습니다.');
        this.render();
    };

    NationListManager.prototype.linkMapToText = function () {
        var ordered = {};
        var map = this.ensureLinkMap();

        Object.keys(map.items || {}).sort(compareWikiLabels).forEach(function (key) {
            var item = Object.assign({}, map.items[key]);
            var tag = getEntryTag(item, key) || key;
            item.tag = normalizeTagInput(tag);
            item.page = item.tag || String(item.page || key || '').trim();
            item.flag_file = normalizeFileTitle(item.flag_file || '');
            item.hover_parent = normalizeTagInput(item.hover_parent || '');
            item.hover_relation = normalizeRelation(item.hover_relation || '');
            ordered[tag] = item;
        });

        return JSON.stringify({
            version: map.version || 1,
            year: map.year || parseInt(this.year, 10),
            description: map.description || (this.year + ' nation hover/click link map.'),
            relation_options: Array.isArray(map.relation_options) ? map.relation_options : [],
            items: ordered
        }, null, 2);
    };

    NationListManager.prototype.saveLinkMap = function () {
        var self = this;
        var title = this.getMapTitle();

        if (!window.mw || !mw.Api) {
            return Promise.reject(new Error('mw.Api를 사용할 수 없습니다.'));
        }

        return new Promise(function (resolve, reject) {
            new mw.Api().postWithToken('csrf', {
                action: 'edit',
                title: title,
                text: self.linkMapToText(),
                summary: 'Update ' + self.year + ' nation link map from Nation List Manager',
                format: 'json'
            }).done(function () {
                self.linkMapDirty = false;
                self.linkMapExists = true;
                self.inferMissingTagsFromMap();
                self.mapTags = getLinkMapTags(self.linkMap);
                resolve();
            }).fail(function (code, data) {
                var message = data && data.error && data.error.info ? data.error.info : code;
                reject(new Error(message));
            });
        });
    };

    NationListManager.prototype.getWarnings = function () {
        var mapTags = this.linkMap ? getLinkMapTags(this.linkMap) : null;
        var listTags = {};
        var missingTag = [];
        var missingInMap = [];
        var missingInList = [];
        var flagMismatch = [];
        var tagValidation = this.validateTags();

        if (mapTags === null) {
            return [{ type: 'soft', text: this.year + '년 링크맵을 불러오지 못했습니다. 링크맵 문서가 없으면 이 경고는 정상입니다.' }];
        }

        this.getAllItems().forEach(function (row) {
            var item = row.item || {};
            var tag = getItemTag(item);
            var label = item.label || item.page || '(이름 없음)';
            var status;

            if (item.enabled === false || item.external_url) return;

            if (!tag) {
                missingTag.push(label);
                return;
            }

            listTags[tag] = true;
            if (!mapTags[tag]) {
                missingInMap.push(tag + ':' + label);
                return;
            }

            status = this.getLinkStatus(item);
            if (status.type === 'flag-mismatch') flagMismatch.push(tag);
        }, this);

        Object.keys(mapTags).forEach(function (tag) {
            if (!listTags[tag]) missingInList.push(tag);
        });

        if (!missingTag.length && !missingInMap.length && !missingInList.length && !flagMismatch.length && !tagValidation.duplicate.length && !tagValidation.invalid.length) {
            return [{ type: 'ok', text: '국가 목록과 링크맵이 TAG 기준으로 맞습니다. 국기는 Nation List 기준으로 연동됩니다.' }];
        }

        return [
            tagValidation.duplicate.length ? { type: 'warn', text: '중복 TAG: ' + tagValidation.duplicate.slice(0, 40).join(', ') + (tagValidation.duplicate.length > 40 ? ' 외 ' + (tagValidation.duplicate.length - 40) + '개' : '') } : null,
            tagValidation.invalid.length ? { type: 'warn', text: '3글자가 아닌 TAG: ' + tagValidation.invalid.slice(0, 40).join(', ') + (tagValidation.invalid.length > 40 ? ' 외 ' + (tagValidation.invalid.length - 40) + '개' : '') } : null,
            missingTag.length ? { type: 'warn', text: '목록 항목에 TAG 없음: ' + missingTag.slice(0, 80).join(', ') + (missingTag.length > 80 ? ' 외 ' + (missingTag.length - 80) + '개' : '') } : null,
            missingInMap.length ? { type: 'warn', text: '목록에는 있으나 링크맵에 없는 TAG: ' + missingInMap.slice(0, 80).join(', ') + (missingInMap.length > 80 ? ' 외 ' + (missingInMap.length - 80) + '개' : '') } : null,
            missingInList.length ? { type: 'warn', text: '링크맵에는 있으나 목록에 없는 TAG: ' + missingInList.slice(0, 80).join(', ') + (missingInList.length > 80 ? ' 외 ' + (missingInList.length - 80) + '개' : '') } : null,
            flagMismatch.length ? { type: 'warn', text: 'Nation List 국기와 링크맵 국기가 다른 TAG: ' + flagMismatch.slice(0, 80).join(', ') + (flagMismatch.length > 80 ? ' 외 ' + (flagMismatch.length - 80) + '개' : '') } : null
        ].filter(Boolean);
    };

    NationListManager.prototype.toText = function () {
        var out = normalizeState(this.state, this.year);
        out.year = parseInt(this.year, 10);
        out.description = this.state.description || ('국가 및 조합 ' + this.year + '년 당대 존재 국가 패널 데이터. Manage:Nation_List에서 편집한다.');
        return JSON.stringify(out, null, 2);
    };

    NationListManager.prototype.save = function () {
        var self = this;
        var title = this.getListTitle();

        if (!window.mw || !mw.Api) {
            this.setStatus('저장 실패: mw.Api를 사용할 수 없습니다.');
            return;
        }

        var tagValidation = this.validateTags();

        if (tagValidation.duplicate.length || tagValidation.invalid.length) {
            this.setStatus('저장 중단: TAG 중복 또는 3글자가 아닌 TAG가 있습니다. 위 경고를 먼저 정리하세요.');
            this.render();
            return;
        }

        this.setStatus('국기 보정값 계산 중...');

        this.computeFlagOffsetsForSave().then(function () {
            self.setStatus(title + ' 저장 중...');

            new mw.Api().postWithToken('csrf', {
                action: 'edit',
                title: title,
                text: self.toText(),
                summary: 'Update ' + self.year + ' nation list',
                format: 'json'
            }).done(function () {
                self.sourceExists = true;

                if (!self.linkMapDirty) {
                    self.setStatus(title + ' 저장 완료');
                    self.loadMapWarnings().then(function () { self.render(); });
                    return;
                }

                self.setStatus(title + ' 저장 완료 · 링크맵 저장 중...');
                self.saveLinkMap().then(function () {
                    self.setStatus(title + ' 및 ' + self.getMapTitle() + ' 저장 완료');
                    return self.loadMapWarnings();
                }).then(function () {
                    self.render();
                }).catch(function (err) {
                    self.setStatus('목록 저장 완료 / 링크맵 저장 실패: ' + (err && err.message ? err.message : err));
                });
            }).fail(function (code, data) {
                var message = data && data.error && data.error.info ? data.error.info : code;
                self.setStatus('저장 실패: ' + message);
            });
        }).catch(function (err) {
            self.setStatus('국기 보정 계산 실패: ' + (err && err.message ? err.message : err));
        });
    };

    NationListManager.prototype.computeFlagOffsetsForSave = function () {
        var self = this;
        var rows = [];
        var fontsReady;

        this.getAllItems().forEach(function (row) {
            var item = row.item || {};
            var label = item.label || item.page || item.external_url || '';
            var flags = Array.isArray(item.flags) ? item.flags : [];

            flags = flags.map(normalizeFlag);

            flags.forEach(function (flag) {
                if (!flag.file) return;

                if (flag.offsetMode === 'manual') {
                    flag.offsetY = getFlagOffsetY(flag);
                    return;
                }

                rows.push({ flag: flag, label: label });
            });

            item.flags = flags;
        });

        if (!rows.length) return Promise.resolve();

        fontsReady = document.fonts && document.fonts.ready ? document.fonts.ready.catch(function () {}) : Promise.resolve();

        return fontsReady.then(function () {
            return self.measureFlagOffsets(rows);
        });
    };

    NationListManager.prototype.measureFlagOffsets = function (rows) {
        var mount = document.createElement('div');
        var waiters = [];
        var timeout;

        mount.className = 'nation-list-measure-stage';
        mount.setAttribute('aria-hidden', 'true');
        document.body.appendChild(mount);

        rows.forEach(function (row, index) {
            var item = document.createElement('span');
            var img = document.createElement('img');
            var label = document.createElement('span');
            var width = String(row.flag.width || '16px').trim() || '16px';

            item.className = 'clbi-nations-list-item nation-list-measure-item';
            item.setAttribute('data-measure-index', String(index));

            img.className = 'clbi-nations-list-flag' + (row.flag.border !== false ? ' has-border' : '');
            img.alt = '';
            img.src = buildFileRedirectUrl(row.flag.file);
            img.style.width = width;
            img.style.setProperty('--flag-y', '0px');

            label.className = 'clbi-nations-list-label nation-list-measure-label';
            label.textContent = row.label || '미지정';

            item.appendChild(img);
            item.appendChild(label);
            mount.appendChild(item);

            if (!img.complete) {
                waiters.push(new Promise(function (resolve) {
                    img.addEventListener('load', resolve, { once: true });
                    img.addEventListener('error', resolve, { once: true });
                }));
            }
        });

        timeout = new Promise(function (resolve) {
            window.setTimeout(resolve, 2200);
        });

        return Promise.race([Promise.all(waiters), timeout]).then(function () {
            rows.forEach(function (row, index) {
                var item = mount.querySelector('[data-measure-index="' + index + '"]');
                var img = item ? item.querySelector('.clbi-nations-list-flag') : null;
                var label = item ? item.querySelector('.nation-list-measure-label') : null;
                var imgRect;
                var labelRect;
                var offset = 0;

                if (img && label) {
                    imgRect = img.getBoundingClientRect();
                    labelRect = label.getBoundingClientRect();

                    if (imgRect.height && labelRect.height) {
                        offset = Math.round(((labelRect.top + labelRect.height / 2) - (imgRect.top + imgRect.height / 2)) * 10) / 10;
                    }
                }

                row.flag.offsetMode = 'auto';
                row.flag.offsetY = Math.round(clampNumber(offset, -3, 3));
            });
        }).finally(function () {
            mount.remove();
        });
    };

    NationListManager.prototype.setStatus = function (text) {
        this.status = text || '';
        var el = this.root.querySelector('.nation-list-status');
        if (el) el.textContent = this.status;
    };

    NationListManager.prototype.renderShell = function () {
        this.root.innerHTML = '<div class="nation-list-loading">' + escapeHtml(this.status || 'loading...') + '</div>';
    };

    NationListManager.prototype.render = function () {
        var state = this.state || makeEmptyState(this.year);
        var active = this.getActiveContinent();
        var allItems = this.getAllItems();
        var enabledCount = allItems.filter(function (row) { return row.item.enabled !== false; }).length;
        var warnings = this.getWarnings();

        this.root.innerHTML = '' +
            '<div class="nation-list-shell">' +
                '<div class="nation-list-head">' +
                    '<div class="nation-list-title">Nation List Manager</div>' +
                    '<div class="nation-list-summary">' + escapeHtml(this.year) + '년 · 전체 ' + allItems.length + ' · 사용 ' + enabledCount + ' · ' + (this.sourceExists ? '문서 있음' : '새 문서') + '</div>' +
                '</div>' +
                '<div class="nation-list-toolbar">' +
                    '<label>연도 <select class="nation-list-year-select">' + this.years.map(function (year) {
                        return '<option value="' + escapeHtml(year) + '"' + (year === this.year ? ' selected' : '') + '>' + escapeHtml(year) + '</option>';
                    }, this).join('') + '</select></label>' +
                    '<input type="search" class="nation-list-search" placeholder="TAG, 문서명, 표시명, 국기 검색" value="' + escapeHtml(this.query) + '">' +
                    '<button type="button" class="nation-list-map-sync-flags">국기→TAG맵</button>' +
                    '<button type="button" class="nation-list-reload">다시 불러오기</button>' +
                    '<button type="button" class="nation-list-save">저장</button>' +
                    '<span class="nation-list-status">' + escapeHtml(this.status) + (this.linkMapDirty ? ' · 링크맵 수정됨' : '') + '</span>' +
                '</div>' +
                '<div class="nation-list-warning-box">' + warnings.map(function (warning) {
                    return '<div class="nation-list-warning is-' + escapeHtml(warning.type) + '">' + escapeHtml(warning.text) + '</div>';
                }).join('') + '</div>' +
                '<div class="nation-list-continent-tabs">' + state.continents.map(function (continent) {
                    return '<button type="button" class="nation-list-continent-tab' + (active && active.key === continent.key ? ' is-active' : '') + '" data-continent="' + escapeHtml(continent.key) + '">' + escapeHtml(continent.label) + '</button>';
                }).join('') + '</div>' +
                '<div class="nation-list-body">' + (active ? this.renderContinent(active) : '') + '</div>' +
                '<div class="nation-list-flag-picker" hidden></div>' +
            '</div>';

        this.bind();
    };

    NationListManager.prototype.renderContinent = function (continent) {
        return (continent.regions || []).map(function (region) {
            return this.renderRegion(continent, region);
        }, this).join('');
    };

    NationListManager.prototype.renderRegion = function (continent, region) {
        var items = (region.items || []).filter(function (item) {
            var q = this.query.trim().toLowerCase();
            var flag = getItemPrimaryFlag(item);
            var haystack = [item.tag, item.page, item.label, item.external_url, flag.file, item.note].join(' ').toLowerCase();
            return !q || haystack.indexOf(q) !== -1;
        }, this);

        return '' +
            '<section class="nation-list-region" data-continent="' + escapeHtml(continent.key) + '" data-region="' + escapeHtml(region.key) + '">' +
                '<div class="nation-list-region-head">' +
                    '<div><span class="nation-list-region-title">' + escapeHtml(region.label) + '</span><span class="nation-list-region-count">' + (region.items || []).length + '개</span></div>' +
                    '<button type="button" class="nation-list-add" data-continent="' + escapeHtml(continent.key) + '" data-region="' + escapeHtml(region.key) + '">항목 추가</button>' +
                '</div>' +
                '<div class="nation-list-table-wrap">' +
                    '<table class="nation-list-table">' +
                        '<thead><tr>' +
                            '<th class="nation-list-drag-col">이동</th>' +
                            '<th class="nation-list-tag-col">TAG</th>' +
                            '<th>문서명 / 외부 URL</th>' +
                            '<th>표시명</th>' +
                            '<th class="nation-list-link-col">링크</th>' +
                            '<th>국기 파일</th>' +
                            '<th class="nation-list-small-col">크기</th>' +
                            '<th class="nation-list-check-col">테두리</th>' +
                            '<th class="nation-list-offset-col">국기 보정</th>' +
                            '<th>메모</th>' +
                            '<th class="nation-list-check-col">사용</th>' +
                            '<th class="nation-list-action-col">작업</th>' +
                        '</tr></thead>' +
                        '<tbody data-continent="' + escapeHtml(continent.key) + '" data-region="' + escapeHtml(region.key) + '">' + (items.length ? items.map(function (item) {
                            return this.renderItemRow(continent, region, item, region.items.indexOf(item));
                        }, this).join('') : '<tr><td colspan="12" class="nation-list-empty">이 지역에는 항목이 없습니다.</td></tr>') + '</tbody>' +
                    '</table>' +
                '</div>' +
            '</section>';
    };

    NationListManager.prototype.renderOffsetSelect = function (flag) {
        var mode = getFlagOffsetMode(flag);
        var offset = getFlagOffsetY(flag);
        var values = [
            { value: 'auto', label: '자동' },
            { value: '-3', label: '-3px' },
            { value: '-2', label: '-2px' },
            { value: '-1', label: '-1px' },
            { value: '0', label: '0px' },
            { value: '1', label: '+1px' },
            { value: '2', label: '+2px' },
            { value: '3', label: '+3px' }
        ];
        var selected = mode === 'manual' ? String(offset) : 'auto';

        return '<select data-field="offset">' + values.map(function (item) {
            return '<option value="' + escapeHtml(item.value) + '"' + (item.value === selected ? ' selected' : '') + '>' + escapeHtml(item.label) + '</option>';
        }).join('') + '</select>';
    };

    NationListManager.prototype.renderItemRow = function (continent, region, item, index) {
        var flag = getItemPrimaryFlag(item);
        var flagUrl = buildFileRedirectUrl(flag.file);
        var linkValue = item.external_url ? item.external_url : item.page;

        return '' +
            '<tr draggable="true" data-continent="' + escapeHtml(continent.key) + '" data-region="' + escapeHtml(region.key) + '" data-index="' + index + '">' +
                '<td class="nation-list-drag-col"><span class="nation-list-drag-handle" title="드래그해서 순서 변경">↕</span></td>' +
                '<td class="nation-list-tag-col"><input type="text" data-field="tag" value="' + escapeHtml(item.tag || '') + '" placeholder="AAA" maxlength="3"></td>' +
                '<td><input type="text" data-field="link" value="' + escapeHtml(linkValue) + '" placeholder="문서명 또는 https://..."></td>' +
                '<td><input type="text" data-field="label" value="' + escapeHtml(item.label) + '" placeholder="표시명"></td>' +
                '<td class="nation-list-link-col">' + this.renderLinkStatus(item) + '</td>' +
                '<td><div class="nation-list-flag-cell">' + (flagUrl ? '<img src="' + escapeHtml(flagUrl) + '" alt="">' : '<span></span>') + '<input type="text" data-field="flag" class="nation-list-flag-input" value="' + escapeHtml(flag.file) + '" placeholder="파일명.svg"></div></td>' +
                '<td class="nation-list-small-col"><input type="text" data-field="width" value="' + escapeHtml(flag.width) + '"></td>' +
                '<td class="nation-list-check-col"><input type="checkbox" data-field="border"' + (flag.border !== false ? ' checked' : '') + '></td>' +
                '<td class="nation-list-offset-col">' + this.renderOffsetSelect(flag) + '</td>' +
                '<td><input type="text" data-field="note" value="' + escapeHtml(item.note) + '"></td>' +
                '<td class="nation-list-check-col"><input type="checkbox" data-field="enabled"' + (item.enabled !== false ? ' checked' : '') + '></td>' +
                '<td class="nation-list-action-col"><button type="button" class="nation-list-delete">삭제</button></td>' +
            '</tr>';
    };

    NationListManager.prototype.bind = function () {
        var self = this;
        var yearSelect = this.root.querySelector('.nation-list-year-select');
        var search = this.root.querySelector('.nation-list-search');
        var save = this.root.querySelector('.nation-list-save');
        var reload = this.root.querySelector('.nation-list-reload');
        var mapSyncFlags = this.root.querySelector('.nation-list-map-sync-flags');

        if (yearSelect) {
            yearSelect.addEventListener('change', function () {
                self.activeContinent = 'america';
                self.query = '';
                self.loadYear(yearSelect.value);
            });
        }

        if (search) {
            search.addEventListener('input', function () {
                self.query = search.value;
                self.render();
                var next = self.root.querySelector('.nation-list-search');
                if (next) {
                    next.focus();
                    next.setSelectionRange(next.value.length, next.value.length);
                }
            });
        }

        if (save) save.addEventListener('click', function () { self.save(); });
        if (reload) reload.addEventListener('click', function () { self.loadYear(self.year); });
        if (mapSyncFlags) mapSyncFlags.addEventListener('click', function () { self.syncAllLinkMapFlags(); });

        this.root.querySelectorAll('.nation-list-continent-tab').forEach(function (tab) {
            tab.addEventListener('click', function () {
                self.activeContinent = tab.getAttribute('data-continent') || self.activeContinent;
                self.render();
            });
        });

        this.root.querySelectorAll('.nation-list-add').forEach(function (button) {
            button.addEventListener('click', function () {
                self.addItem(button.getAttribute('data-continent'), button.getAttribute('data-region'));
            });
        });

        this.root.querySelectorAll('.nation-list-table tbody tr[data-index]').forEach(function (tr) {
            tr.querySelectorAll('input[data-field], select[data-field]').forEach(function (input) {
                input.addEventListener('input', function () {
                    self.updateRow(tr, input);
                    if (input.getAttribute('data-field') === 'flag') self.showFlagPicker(input);
                });
                input.addEventListener('change', function () {
                    self.updateRow(tr, input);
                    if (input.getAttribute('data-field') === 'flag') self.render();
                });
                if (input.getAttribute('data-field') === 'flag') {
                    input.addEventListener('focus', function () { self.showFlagPicker(input); });
                    input.addEventListener('keydown', function (event) {
                        if (event.key === 'Escape') self.hideFlagPicker();
                    });
                }
            });

            tr.addEventListener('dragstart', function (event) {
                self.startDrag(tr, event);
            });

            tr.addEventListener('dragover', function (event) {
                self.dragOverRow(tr, event);
            });

            tr.addEventListener('dragleave', function () {
                tr.classList.remove('is-drop-before', 'is-drop-after');
            });

            tr.addEventListener('drop', function (event) {
                self.dropOnRow(tr, event);
            });

            tr.addEventListener('dragend', function () {
                self.clearDragState();
            });

            var createLink = tr.querySelector('.nation-list-link-create');
            if (createLink) {
                createLink.addEventListener('click', function () {
                    var row = self.getRowItem(tr);
                    if (row && self.createLinkEntryForItem(row.item)) {
                        self.setStatus('링크맵 항목 생성됨. 저장을 누르면 목록과 링크맵이 함께 저장됩니다.');
                        self.render();
                    }
                });
            }

            var syncOneFlag = tr.querySelector('.nation-list-link-sync-one');
            if (syncOneFlag) {
                syncOneFlag.addEventListener('click', function () {
                    var row = self.getRowItem(tr);
                    if (row && self.syncLinkMapFlagForItem(row.item)) {
                        self.setStatus('이 항목의 링크맵 국기를 Nation List 기준으로 동기화했습니다.');
                        self.render();
                    }
                });
            }

            var del = tr.querySelector('.nation-list-delete');
            if (del) {
                del.addEventListener('click', function () {
                    self.deleteItem(tr);
                });
            }
        });


        this.root.querySelectorAll('.nation-list-table tbody[data-continent][data-region]').forEach(function (tbody) {
            tbody.addEventListener('dragover', function (event) {
                self.dragOverRegion(tbody, event);
            });

            tbody.addEventListener('drop', function (event) {
                self.dropOnRegion(tbody, event);
            });
        });

        document.addEventListener('pointerdown', function onDocPointerDown(event) {
            if (!self.root.contains(event.target)) {
                self.hideFlagPicker();
                document.removeEventListener('pointerdown', onDocPointerDown);
            }
        });
    };

    NationListManager.prototype.findRegion = function (continentKey, regionKey) {
        var continent = (this.state.continents || []).find(function (item) { return item.key === continentKey; });
        if (!continent) return null;
        return (continent.regions || []).find(function (item) { return item.key === regionKey; }) || null;
    };

    NationListManager.prototype.getRowItem = function (tr) {
        var region = this.findRegion(tr.getAttribute('data-continent'), tr.getAttribute('data-region'));
        var index = parseInt(tr.getAttribute('data-index') || '-1', 10);
        if (!region || index < 0 || !region.items || !region.items[index]) return null;
        return { region: region, index: index, item: region.items[index] };
    };

    NationListManager.prototype.updateRow = function (tr, input) {
        var row = this.getRowItem(tr);
        var field = input.getAttribute('data-field');
        var flag;
        var value;

        if (!row) return;
        flag = getItemPrimaryFlag(row.item);
        value = input.type === 'checkbox' ? input.checked : input.value;

        if (field === 'tag') {
            var oldTag = getItemTag(row.item);
            var newTag = normalizeTagInput(value);
            input.value = newTag;
            row.item.tag = newTag;
            if (oldTag && isValidTag(newTag) && oldTag !== newTag) this.renameLinkMapTag(oldTag, newTag);
        } else if (field === 'link') {
            value = String(value || '').trim();
            if (/^https?:\/\//i.test(value)) {
                row.item.external_url = value;
                row.item.page = '';
            } else {
                row.item.page = value;
                row.item.external_url = '';
            }
        } else if (field === 'label') {
            row.item.label = value;
            if (this.getLinkEntry(row.item) && !this.getLinkEntry(row.item).name_ko) {
                this.getLinkEntry(row.item).name_ko = String(value || '').trim();
                this.linkMapDirty = true;
            }
        } else if (field === 'flag') {
            flag.file = normalizeFileTitle(value);
            this.syncLinkMapFlagForItem(row.item);
        } else if (field === 'width') {
            flag.width = String(value || '').trim() || '16px';
        } else if (field === 'border') {
            flag.border = !!value;
        } else if (field === 'offset') {
            if (String(value) === 'auto') {
                flag.offsetMode = 'auto';
            } else {
                flag.offsetMode = 'manual';
                flag.offsetY = Math.round(clampNumber(value, -3, 3));
            }
        } else if (field === 'note') {
            row.item.note = value;
        } else if (field === 'enabled') {
            row.item.enabled = !!value;
        }

        this.setStatus('수정됨');
    };

    NationListManager.prototype.showFlagPicker = function (input) {
        var self = this;
        var picker = this.root.querySelector('.nation-list-flag-picker');
        var query = normalizeFileTitle(input.value).toLowerCase();
        var rect;
        var rootRect;

        if (!picker) return;
        this.activeFlagInput = input;
        this.flagPicker = picker;
        picker.hidden = false;
        picker.innerHTML = '<div class="nation-list-flag-picker-loading">flag 파일 검색 중...</div>';

        rect = input.getBoundingClientRect();
        rootRect = this.root.getBoundingClientRect();
        picker.style.left = Math.max(0, rect.left - rootRect.left) + 'px';
        picker.style.top = Math.max(0, rect.bottom - rootRect.top + 2) + 'px';
        picker.style.width = Math.max(280, rect.width + 44) + 'px';

        this.loadFlagFiles().then(function (list) {
            var filtered = list.filter(function (item) {
                return !query || item.file.toLowerCase().indexOf(query) !== -1;
            }).slice(0, 48);

            if (!filtered.length) {
                picker.innerHTML = '<div class="nation-list-flag-picker-empty">flag 파일 없음 · 직접 입력 가능</div>';
                return;
            }

            picker.innerHTML = filtered.map(function (item) {
                return '' +
                    '<button type="button" class="nation-list-flag-option" data-file="' + escapeHtml(item.file) + '">' +
                        '<img src="' + escapeHtml(item.thumb || buildFileRedirectUrl(item.file)) + '" alt="">' +
                        '<span>' + escapeHtml(item.file) + '</span>' +
                    '</button>';
            }).join('');

            picker.querySelectorAll('.nation-list-flag-option').forEach(function (btn) {
                btn.addEventListener('click', function () {
                    var file = btn.getAttribute('data-file') || '';
                    var tr;

                    if (!self.activeFlagInput) return;
                    self.activeFlagInput.value = file;
                    tr = self.activeFlagInput.closest('tr');
                    if (tr) self.updateRow(tr, self.activeFlagInput);
                    self.setStatus('수정됨');
                    self.hideFlagPicker();
                    self.render();
                });
            });
        });
    };

    NationListManager.prototype.hideFlagPicker = function () {
        var picker = this.flagPicker || this.root.querySelector('.nation-list-flag-picker');
        if (picker) {
            picker.hidden = true;
            picker.innerHTML = '';
        }
        this.activeFlagInput = null;
    };

    NationListManager.prototype.addItem = function (continentKey, regionKey) {
        var region = this.findRegion(continentKey, regionKey);
        if (!region) return;
        region.items = region.items || [];
        region.items.push(normalizeItem({ tag: '', page: '', label: '', flags: [normalizeFlag({})], enabled: true }));
        this.setStatus('항목 추가됨');
        this.render();
    };

    NationListManager.prototype.deleteItem = function (tr) {
        var row = this.getRowItem(tr);
        if (!row) return;
        row.region.items.splice(row.index, 1);
        this.setStatus('항목 삭제됨');
        this.render();
    };

    NationListManager.prototype.startDrag = function (tr, event) {
        var row = this.getRowItem(tr);
        if (!row) return;

        this.dragSource = {
            continent: tr.getAttribute('data-continent'),
            region: tr.getAttribute('data-region'),
            index: row.index
        };

        tr.classList.add('is-dragging');

        if (event && event.dataTransfer) {
            event.dataTransfer.effectAllowed = 'move';
            event.dataTransfer.setData('text/plain', JSON.stringify(this.dragSource));
        }
    };

    NationListManager.prototype.clearDragState = function () {
        this.dragSource = null;
        this.root.querySelectorAll('.is-dragging, .is-drop-before, .is-drop-after, .is-region-drop').forEach(function (node) {
            node.classList.remove('is-dragging', 'is-drop-before', 'is-drop-after', 'is-region-drop');
        });
    };

    NationListManager.prototype.dragOverRow = function (tr, event) {
        var rect;
        var after;

        if (!this.dragSource) return;
        event.preventDefault();
        rect = tr.getBoundingClientRect();
        after = event.clientY > rect.top + rect.height / 2;

        this.root.querySelectorAll('.is-drop-before, .is-drop-after').forEach(function (node) {
            if (node !== tr) node.classList.remove('is-drop-before', 'is-drop-after');
        });

        tr.classList.toggle('is-drop-before', !after);
        tr.classList.toggle('is-drop-after', after);

        if (event.dataTransfer) event.dataTransfer.dropEffect = 'move';
    };

    NationListManager.prototype.dragOverRegion = function (tbody, event) {
        if (!this.dragSource) return;
        event.preventDefault();
        tbody.classList.add('is-region-drop');
        if (event.dataTransfer) event.dataTransfer.dropEffect = 'move';
    };

    NationListManager.prototype.moveDraggedItem = function (targetContinent, targetRegion, targetIndex) {
        var source = this.dragSource;
        var sourceRegion;
        var destRegion;
        var item;

        if (!source) return false;

        sourceRegion = this.findRegion(source.continent, source.region);
        destRegion = this.findRegion(targetContinent, targetRegion);

        if (!sourceRegion || !destRegion || !sourceRegion.items || !destRegion.items) return false;
        if (source.index < 0 || source.index >= sourceRegion.items.length) return false;

        item = sourceRegion.items.splice(source.index, 1)[0];

        if (sourceRegion === destRegion && targetIndex > source.index) {
            targetIndex -= 1;
        }

        targetIndex = Math.max(0, Math.min(targetIndex, destRegion.items.length));
        destRegion.items.splice(targetIndex, 0, item);
        return true;
    };

    NationListManager.prototype.dropOnRow = function (tr, event) {
        var target = this.getRowItem(tr);
        var rect;
        var targetIndex;

        if (!this.dragSource || !target) return;
        event.preventDefault();
        event.stopPropagation();

        rect = tr.getBoundingClientRect();
        targetIndex = target.index + (event.clientY > rect.top + rect.height / 2 ? 1 : 0);

        if (this.moveDraggedItem(tr.getAttribute('data-continent'), tr.getAttribute('data-region'), targetIndex)) {
            this.setStatus('순서 변경됨');
            this.clearDragState();
            this.render();
        }
    };

    NationListManager.prototype.dropOnRegion = function (tbody, event) {
        var targetContinent;
        var targetRegion;
        var region;

        if (!this.dragSource) return;
        if (event.target && event.target.closest && event.target.closest('tr[data-index]')) return;
        event.preventDefault();

        targetContinent = tbody.getAttribute('data-continent');
        targetRegion = tbody.getAttribute('data-region');
        region = this.findRegion(targetContinent, targetRegion);

        if (!region) return;

        if (this.moveDraggedItem(targetContinent, targetRegion, region.items.length)) {
            this.setStatus('순서 변경됨');
            this.clearDragState();
            this.render();
        }
    };

    function init(root) {
        (root || document).querySelectorAll('.nation-list-manager').forEach(function (node) {
            if (node.getAttribute('data-nation-list-manager-ready') === '1') return;
            node.setAttribute('data-nation-list-manager-ready', '1');
            new NationListManager(node).init();
        });
    }

    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', function () { init(document); });
    } else {
        init(document);
    }

    if (window.mw && mw.hook) {
        mw.hook('wikipage.content').add(function ($content) {
            init($content && $content[0] ? $content[0] : document);
        });
    }
}());