(Install package: dd / js/DevTools.js) |
(Install package: 고 / js/DevTools.js) |
||
| 743번째 줄: | 743번째 줄: | ||
var collapsed = localStorage.getItem('dev-tools-section-decoration-collapsed'); | var collapsed = localStorage.getItem('dev-tools-section-decoration-collapsed'); | ||
var isCollapsed = collapsed === null ? false : collapsed === 'true'; | var isCollapsed = collapsed === null ? false : collapsed === 'true'; | ||
var gridOn = localStorage.getItem('dev-tools-decoration-grid-enabled') !== 'false'; | |||
return '' + | return '' + | ||
| 751번째 줄: | 752번째 줄: | ||
'</button>' + | '</button>' + | ||
'<div class="dev-tools-section-content">' + | '<div class="dev-tools-section-content">' + | ||
'<div class="dev-tools-decoration-page" id="dev-tools-decoration-page">PAGE: SYNC</div>' + | '<div class="dev-tools-decoration-headline">' + | ||
'< | '<div class="dev-tools-decoration-mode" id="dev-tools-decoration-mode">VIEW MODE</div>' + | ||
'< | '<div class="dev-tools-decoration-page" id="dev-tools-decoration-page">PAGE: SYNC</div>' + | ||
'<label class="dev-tools-label" for="dev-tools-decoration- | '</div>' + | ||
'<input id="dev-tools-decoration- | '<div class="dev-tools-decoration-mainbar">' + | ||
'<button type="button" class="dev-tools-button" id="dev-tools-decoration-edit-toggle">EDIT MODE</button>' + | |||
'<button type="button" class="dev-tools-button" id="dev-tools-decoration-save" disabled>SAVE</button>' + | |||
'<button type="button" class="dev-tools-button" id="dev-tools-decoration-cancel" disabled>CANCEL</button>' + | |||
'</div>' + | |||
'<div class="dev-tools-decoration-layerbar">' + | |||
'<button type="button" class="dev-tools-button" id="dev-tools-decoration-add" disabled>+ LAYER</button>' + | |||
'<button type="button" class="dev-tools-button dev-tools-danger" id="dev-tools-decoration-delete" disabled>DELETE</button>' + | |||
'<button type="button" class="dev-tools-button" id="dev-tools-decoration-grid-toggle" data-grid="' + (gridOn ? '1' : '0') + '">' + (gridOn ? 'GRID ON' : 'GRID OFF') + '</button>' + | |||
'</div>' + | |||
'<div class="dev-tools-decoration-layer-list" id="dev-tools-decoration-layer-list">SYNC</div>' + | |||
'<label class="dev-tools-label" for="dev-tools-decoration-label">LAYER NAME</label>' + | |||
'<input id="dev-tools-decoration-label" class="dev-tools-input" type="text" value="" placeholder="layer name">' + | |||
'<label class="dev-tools-label" for="dev-tools-decoration-src">IMAGE FILE / URL</label>' + | '<label class="dev-tools-label" for="dev-tools-decoration-src">IMAGE FILE / URL</label>' + | ||
'<input id="dev-tools-decoration-src" class="dev-tools-input" type="text" placeholder="Example_Deco_1372.webp">' + | '<input id="dev-tools-decoration-src" class="dev-tools-input" type="text" placeholder="Example_Deco_1372.webp">' + | ||
'<label class="dev-tools-label" for="dev-tools-decoration-target">TARGET SELECTOR</label>' + | |||
'<input id="dev-tools-decoration-target" class="dev-tools-input" type="text" value=".liberty-content-main">' + | |||
'<div class="dev-tools-decoration-continent-row">' + | |||
'<div class="dev-tools-decoration-continent-readout" id="dev-tools-decoration-continent-readout">ERA: NONE / CONTINENT: NONE</div>' + | |||
'<button type="button" class="dev-tools-button" id="dev-tools-decoration-target-continent" disabled>TARGET</button>' + | |||
'</div>' + | |||
'<div class="dev-tools-decoration-scope-grid">' + | |||
'<label><span>ERA</span><input id="dev-tools-decoration-era" class="dev-tools-input" type="text" value="" placeholder="optional"></label>' + | |||
'<label><span>CONTINENT</span><input id="dev-tools-decoration-continent" class="dev-tools-input" type="text" value="" placeholder="optional"></label>' + | |||
'</div>' + | |||
'<div class="dev-tools-decoration-grid">' + | '<div class="dev-tools-decoration-grid">' + | ||
'<label><span>X</span><input id="dev-tools-decoration-x" class="dev-tools-input" type="number" step="1" value="0"></label>' + | '<label><span>X</span><input id="dev-tools-decoration-x" class="dev-tools-input" type="number" step="1" value="0"></label>' + | ||
| 766번째 줄: | 789번째 줄: | ||
'<label><span>Z</span><input id="dev-tools-decoration-z" class="dev-tools-input" type="number" step="1" value="0"></label>' + | '<label><span>Z</span><input id="dev-tools-decoration-z" class="dev-tools-input" type="number" step="1" value="0"></label>' + | ||
'</div>' + | '</div>' + | ||
'<div class="dev-tools-decoration- | '<div class="dev-tools-decoration-quickbar">' + | ||
'<button type="button" class="dev-tools-button" id="dev-tools-decoration-center" disabled>CENTER</button>' + | |||
'<button type="button" class="dev-tools-button" id="dev-tools-decoration-fit-width" disabled>FIT W</button>' + | |||
'<button type="button" class="dev-tools-button" id="dev-tools-decoration-center | |||
'<button type="button" class="dev-tools-button" id="dev-tools-decoration-fit-width">FIT W | |||
'</div>' + | '</div>' + | ||
'<div class="dev-tools-note"> | '<input id="dev-tools-decoration-id" type="hidden" value="">' + | ||
'<div class="dev-tools-note">EDIT MODE에서만 조작합니다. 저장 전 변경은 CANCEL로 폐기됩니다. 레이어를 선택한 뒤 드래그/방향키로 이동하고, Shift+드래그는 수평·수직 축을 잠급니다. GRID ON은 중앙 가이드와 중앙 스냅을 켭니다.</div>' + | |||
'</div>' + | '</div>' + | ||
'</div>'; | '</div>'; | ||
| 2,380번째 줄: | 2,384번째 줄: | ||
} | } | ||
function | function cssAttrEscape(value) { | ||
return String(value || '').replace(/\\/g, '\\\\').replace(/"/g, '\\"'); | |||
} | } | ||
function | function getActiveDecorationEra() { | ||
var content = document.querySelector('.clbi-nations-era-content.is-active[data-era-content]:not([hidden])'); | |||
if ( | var title; | ||
if ( | var globe; | ||
return ' | |||
if (content) return content.getAttribute('data-era-content') || ''; | |||
title = document.querySelector('.clbi-nations-era-title-plate.is-active[data-era]'); | |||
if (title) return title.getAttribute('data-era') || ''; | |||
globe = document.querySelector('.clbi-nations-globe-window[data-nations-globe]'); | |||
if (globe) { | |||
return globe.getAttribute('data-current-era') || globe.getAttribute('data-nations-current-era') || globe.getAttribute('data-era-year') || ''; | |||
} | |||
return ''; | |||
} | } | ||
function | function getActiveDecorationEraPanel() { | ||
var | var era = getActiveDecorationEra(); | ||
return | var selector; | ||
if (!era) return document.querySelector('.clbi-nations-era-content.is-active[data-era-content]:not([hidden])'); | |||
selector = '.clbi-nations-era-content[data-era-content="' + cssAttrEscape(era) + '"]'; | |||
return document.querySelector(selector) || document.querySelector('.clbi-nations-era-content.is-active[data-era-content]:not([hidden])'); | |||
} | } | ||
function | function getActiveDecorationContinent() { | ||
var | var eraPanel = getActiveDecorationEraPanel(); | ||
var | var root = eraPanel || document; | ||
var tab = root.querySelector('.clbi-nations-tabpanel-tab.is-active[data-continent], .clbi-nations-tabpanel-tab[aria-selected="true"][data-continent]'); | |||
var panel; | |||
if (tab) return tab.getAttribute('data-continent') || ''; | |||
panel = root.querySelector('.clbi-nations-tabpanel-continent.is-active[data-continent-panel]'); | |||
if (panel) return panel.getAttribute('data-continent-panel') || ''; | |||
return ''; | |||
return | |||
} | } | ||
function | function getActiveDecorationScopeSelector() { | ||
var era = getActiveDecorationEra(); | |||
var continent = getActiveDecorationContinent(); | |||
var selector = '.clbi-nations-panel-stack'; | |||
if (era) selector += ' .clbi-nations-era-content[data-era-content="' + cssAttrEscape(era) + '"]'; | |||
if (continent) selector += ' .clbi-nations-tabpanel-continent[data-continent-panel="' + cssAttrEscape(continent) + '"]'; | |||
return continent || era ? selector : ''; | |||
} | } | ||
function | function getDefaultDecorationScopedTarget() { | ||
return | return document.querySelector('.clbi-nations-panel-stack') ? '.clbi-nations-panel-stack' : getDefaultDecorationTarget(); | ||
} | } | ||
function | function refreshDecorationScopeReadout() { | ||
var era = getActiveDecorationEra(); | |||
var continent = getActiveDecorationContinent(); | |||
var readout = $('#dev-tools-decoration-continent-readout'); | |||
var scope = $('#dev-tools-decoration-scope-readout'); | |||
if (readout.length) { | |||
readout.text('ERA: ' + (era || 'NONE') + ' / CONTINENT: ' + (continent || 'NONE')); | |||
} | |||
if (scope.length) { | |||
scope.text('SCOPE: ' + (era || 'PAGE') + (continent ? (' / ' + continent) : '')); | |||
} | |||
} | } | ||
function | function normalizeDecorationId(value) { | ||
value = String(value || '').trim(); | |||
if (value) return value; | |||
return 'decor-' + getDecorationPageKey().replace(/[^a-zA-Z0-9가-힣_-]+/g, '-').replace(/^-+|-+$/g, '') + '-' + Date.now(); | |||
} | } | ||
function | function normalizeDecorationSrc(src) { | ||
src = String(src || '').trim(); | |||
if ( | if (!src) return ''; | ||
return | if (/^(?:https?:)?\/\//i.test(src) || src.charAt(0) === '/' || src.indexOf('data:') === 0 || src.indexOf('blob:') === 0) return src; | ||
return '/index.php/Special:Redirect/file/' + encodeURIComponent(src); | |||
} | } | ||
function | function readDecorationNumber(selector, fallback) { | ||
$( | var n = parseFloat($(selector).val()); | ||
return Number.isFinite(n) ? n : fallback; | |||
} | } | ||
function | function readDecorationHeight() { | ||
var | var raw = String($('#dev-tools-decoration-height').val() || '').trim(); | ||
var n; | |||
var | if (!raw || raw.toLowerCase() === 'auto') return null; | ||
n = parseFloat(raw); | |||
return Number.isFinite(n) && n > 0 ? Math.round(n) : null; | |||
} | } | ||
function | function readDecorationForm() { | ||
var height = readDecorationHeight(); | |||
var entry = { | |||
id: normalizeDecorationId($('#dev-tools-decoration-id').val()), | |||
page: getDecorationPageKey(), | |||
target: String($('#dev-tools-decoration-target').val() || '').trim() || getDefaultDecorationTarget(), | |||
era: String($('#dev-tools-decoration-era').val() || '').trim(), | |||
continent: String($('#dev-tools-decoration-continent').val() || '').trim(), | |||
src: String($('#dev-tools-decoration-src').val() || '').trim(), | |||
x: Math.round(readDecorationNumber('#dev-tools-decoration-x', 0)), | |||
y: Math.round(readDecorationNumber('#dev-tools-decoration-y', 0)), | |||
width: Math.max(1, Math.round(readDecorationNumber('#dev-tools-decoration-width', 1372))), | |||
opacity: Math.max(0, Math.min(1, readDecorationNumber('#dev-tools-decoration-opacity', 1))), | |||
zIndex: Math.round(readDecorationNumber('#dev-tools-decoration-z', 0)), | |||
pointerEvents: 'none', | |||
enabled: true | |||
}; | |||
if (height) entry.height = height; | |||
if (!entry.era) delete entry.era; | |||
if (!entry.continent) delete entry.continent; | |||
return entry; | |||
} | } | ||
function | function writeDecorationForm(entry) { | ||
entry = entry || {}; | |||
$('#dev-tools-decoration-id').val(entry.id || normalizeDecorationId('')); | |||
$('#dev-tools-decoration-target').val(entry.target || getDefaultDecorationTarget()); | |||
$('#dev-tools-decoration-era').val(entry.era || ''); | |||
$('#dev-tools-decoration-continent').val(entry.continent || ''); | |||
$('#dev-tools-decoration-src').val(entry.src || ''); | |||
refreshDecorationScopeReadout(); | |||
$('#dev-tools-decoration-x').val(entry.x == null ? 0 : Math.round(Number(entry.x) || 0)); | |||
$('#dev-tools-decoration-y').val(entry.y == null ? 0 : Math.round(Number(entry.y) || 0)); | |||
$('#dev-tools-decoration-width').val(entry.width == null ? 1372 : Math.max(1, Math.round(Number(entry.width) || 1372))); | |||
$('#dev-tools-decoration-height').val(entry.height == null ? 'auto' : Math.max(1, Math.round(Number(entry.height) || 1))); | |||
$('#dev-tools-decoration-opacity').val(entry.opacity == null ? 1 : Math.max(0, Math.min(1, Number(entry.opacity) || 0))); | |||
$('#dev-tools-decoration-z').val(entry.zIndex == null ? 0 : Math.round(Number(entry.zIndex) || 0)); | |||
} | } | ||
function | function cloneDecorationEntry(entry) { | ||
return JSON.parse(JSON.stringify(entry || {})); | |||
} | } | ||
function | function setDecorationStartFromForm() { | ||
decorationEditor.origin = cloneDecorationEntry(readDecorationForm()); | |||
setStatus('장식 START 위치 저장: X ' + decorationEditor.origin.x + ' / Y ' + decorationEditor.origin.y, 'ok'); | |||
} | } | ||
function | function resetDecorationToStart() { | ||
if (! | if (!decorationEditor.origin) { | ||
decorationEditor.origin = cloneDecorationEntry(readDecorationForm()); | |||
} | |||
writeDecorationForm(decorationEditor.origin); | |||
updateDecorationPreview(true); | |||
setStatus('장식 START 위치로 복귀', 'ok'); | |||
} | } | ||
function | function getDecorationMoveStep(event) { | ||
var step = Math.max(1, Math.round(readDecorationNumber('#dev-tools-decoration-step', 1))); | |||
if (event && event.shiftKey) step *= 10; | |||
return step; | |||
} | |||
function setDecorationPosition(x, y, silent) { | |||
$('#dev-tools-decoration-x').val(Math.round(x)); | |||
$('#dev-tools-decoration-y').val(Math.round(y)); | |||
updateDecorationPreview(true); | |||
if (!silent) setStatus('장식 위치: X ' + Math.round(x) + ' / Y ' + Math.round(y), 'ok'); | |||
} | |||
function nudgeDecoration(dx, dy, event) { | |||
var step = getDecorationMoveStep(event); | |||
var x = readDecorationNumber('#dev-tools-decoration-x', 0) + (dx * step); | |||
var y = readDecorationNumber('#dev-tools-decoration-y', 0) + (dy * step); | |||
setDecorationPosition(x, y, false); | |||
} | } | ||
function | function zeroDecorationPosition() { | ||
setDecorationPosition(0, 0, false); | |||
} | } | ||
function | function getDecorationTargetElement(entry) { | ||
var selector = String(entry && entry.target || '').trim() || getDefaultDecorationTarget(); | |||
var base = document.querySelector(selector); | |||
var era = String(entry && entry.era || '').trim(); | |||
var continent = String(entry && entry.continent || '').trim(); | |||
var eraSelector; | |||
var continentSelector; | |||
var scoped; | |||
if (era) { | |||
eraSelector = '.clbi-nations-era-content[data-era-content="' + cssAttrEscape(era) + '"]'; | |||
if (base) { | |||
if (base.matches && base.matches(eraSelector)) scoped = base; | |||
if (!scoped && base.querySelector) scoped = base.querySelector(eraSelector); | |||
if (!scoped && base.closest) scoped = base.closest(eraSelector) || null; | |||
} | |||
base = scoped || document.querySelector(eraSelector) || base; | |||
} | } | ||
if (continent) { | |||
continentSelector = '.clbi-nations-tabpanel-continent[data-continent-panel="' + cssAttrEscape(continent) + '"]'; | |||
scoped = null; | |||
if (base) { | |||
if (base.matches && base.matches(continentSelector)) scoped = base; | |||
if (!scoped && base.querySelector) scoped = base.querySelector(continentSelector); | |||
if (!scoped && base.closest) scoped = base.closest(continentSelector) || null; | |||
} | |||
base = scoped || document.querySelector(continentSelector) || base; | |||
} | |||
return base; | |||
} | } | ||
function | function centerDecorationX() { | ||
var | var entry = readDecorationForm(); | ||
var target = getDecorationTargetElement(entry); | |||
var width = entry.width || 0; | |||
var x; | |||
if (!target) { | |||
setStatus('대상 컨테이너를 찾지 못했습니다: ' + entry.target, 'error'); | |||
return; | |||
} | |||
x = Math.round(((target.offsetWidth || target.getBoundingClientRect().width || 0) - width) / 2); | |||
setDecorationPosition(x, entry.y || 0, false); | |||
} | } | ||
function | function fitDecorationWidth() { | ||
var | var entry = readDecorationForm(); | ||
if (! | var target = getDecorationTargetElement(entry); | ||
if (!target) { | |||
setStatus('대상 컨테이너를 찾지 못했습니다: ' + entry.target, 'error'); | |||
return; | |||
} | |||
$('#dev-tools-decoration-width').val(Math.max(1, Math.round(target.offsetWidth || target.getBoundingClientRect().width || entry.width || 1))); | |||
updateDecorationPreview(true); | |||
setStatus('장식 너비를 target 폭에 맞춤', 'ok'); | |||
} | |||
function writeDecorationDefaults() { | |||
if (!$('#dev-tools-decoration-page').length) return; | |||
$('#dev-tools-decoration-page').text('PAGE: ' + getDecorationPageKey()); | |||
if (!$('#dev-tools-decoration-id').val()) $('#dev-tools-decoration-id').val(normalizeDecorationId('')); | |||
if (!$('#dev-tools-decoration-target').val()) $('#dev-tools-decoration-target').val(getDefaultDecorationTarget()); | |||
if (!$('#dev-tools-decoration-era').val()) $('#dev-tools-decoration-era').val(''); | |||
if (!$('#dev-tools-decoration-src').val()) $('#dev-tools-decoration-src').val(localStorage.getItem('dev-tools-decoration-last-src') || ''); | |||
refreshDecorationScopeReadout(); | |||
} | |||
function getDecorationRegistryList(registry) { | |||
if (!registry || typeof registry !== 'object') return []; | |||
if (Array.isArray(registry)) return registry; | |||
if (!Array.isArray(registry.decorations)) registry.decorations = []; | |||
return registry.decorations; | |||
} | |||
function fetchDecorationRegistry(api) { | |||
return api.get({ | |||
action: 'query', | |||
prop: 'revisions', | |||
titles: DECORATIONS_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 { version: 1, decorations: [] }; | |||
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 { version: 1, decorations: [] }; | |||
try { | |||
return JSON.parse(text); | |||
} catch (err) { | |||
setStatus('Decorations JSON 파싱 실패', 'error'); | |||
return { version: 1, decorations: [] }; | |||
} | |||
}); | |||
} | |||
function ensureDecorationHost(target) { | |||
var style; | |||
if (!target) return; | |||
target.setAttribute('data-wiki-decoration-host', '1'); | |||
style = window.getComputedStyle ? window.getComputedStyle(target) : null; | |||
if (style && style.position === 'static') target.style.position = 'relative'; | |||
} | |||
function removeDecorationPreview() { | |||
if ( | if (decorationEditor.preview && decorationEditor.preview.parentNode) { | ||
decorationEditor.preview.parentNode.removeChild(decorationEditor.preview); | |||
} | } | ||
decorationEditor.active = | decorationEditor.preview = null; | ||
decorationEditor.target = null; | |||
decorationEditor.drag = null; | |||
decorationEditor.active = false; | |||
$(document).off('.devToolsDecoration'); | |||
} | } | ||
function | function getDecorationTargetScale(target) { | ||
var | var rect; | ||
if (!target) return { x: 1, y: 1 }; | |||
rect = target.getBoundingClientRect(); | |||
return { | |||
if ( | x: rect.width && target.offsetWidth ? rect.width / target.offsetWidth : 1, | ||
y: rect.height && target.offsetHeight ? rect.height / target.offsetHeight : 1 | |||
}; | |||
} | } | ||
function | function bindDecorationPreviewDrag() { | ||
var | var preview = decorationEditor.preview; | ||
if (!preview || preview.getAttribute('data-decoration-drag-ready') === '1') return; | |||
preview.setAttribute('data-decoration-drag-ready', '1'); | |||
if (! | preview.addEventListener('pointerdown', function (event) { | ||
if (!decorationEditor.active) return; | |||
event.preventDefault(); | |||
} | event.stopPropagation(); | ||
decorationEditor.drag = { | |||
pointerId: event.pointerId, | |||
startX: event.clientX, | |||
startY: event.clientY, | |||
x: readDecorationNumber('#dev-tools-decoration-x', 0), | |||
y: readDecorationNumber('#dev-tools-decoration-y', 0) | |||
}; | |||
if (preview.setPointerCapture) preview.setPointerCapture(event.pointerId); | |||
preview.classList.add('is-dragging'); | |||
}); | |||
preview.addEventListener('wheel', function (event) { | |||
var width; | |||
var step; | |||
if (!decorationEditor.active) return; | |||
if (!event.ctrlKey && !event.altKey) return; | |||
event.preventDefault(); | |||
step = event.shiftKey ? 20 : 5; | |||
width = readDecorationNumber('#dev-tools-decoration-width', 1372); | |||
width += event.deltaY < 0 ? step : -step; | |||
$('#dev-tools-decoration-width').val(Math.max(1, Math.round(width))); | |||
updateDecorationPreview(true); | |||
}, { passive: false }); | |||
$(document) | |||
var | .on('pointermove.devToolsDecoration', function (event) { | ||
var drag = decorationEditor.drag; | |||
var scale; | |||
var x; | |||
var y; | |||
if (!drag) return; | |||
scale = getDecorationTargetScale(decorationEditor.target); | |||
x = Math.round(drag.x + (event.clientX - drag.startX) / (scale.x || 1)); | |||
y = Math.round(drag.y + (event.clientY - drag.startY) / (scale.y || 1)); | |||
$('#dev-tools-decoration-x').val(x); | |||
$('#dev-tools-decoration-y').val(y); | |||
if ( | updateDecorationPreview(true); | ||
}) | |||
.on('pointerup.devToolsDecoration pointercancel.devToolsDecoration', function () { | |||
if (decorationEditor.preview) decorationEditor.preview.classList.remove('is-dragging'); | |||
return | decorationEditor.drag = null; | ||
}) | |||
.on('keydown.devToolsDecoration', function (event) { | |||
var tag = event.target && event.target.tagName ? event.target.tagName.toLowerCase() : ''; | |||
var step; | |||
var x; | |||
var y; | |||
if (!decorationEditor.active) return; | |||
if (tag === 'input' || tag === 'textarea' || tag === 'select') return; | |||
if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight' && event.key !== 'ArrowUp' && event.key !== 'ArrowDown') return; | |||
event.preventDefault(); | |||
if ( | step = getDecorationMoveStep(event); | ||
x = readDecorationNumber('#dev-tools-decoration-x', 0); | |||
y = readDecorationNumber('#dev-tools-decoration-y', 0); | |||
if (event.key === 'ArrowLeft') x -= step; | |||
if (event.key === 'ArrowRight') x += step; | |||
if (event.key === 'ArrowUp') y -= step; | |||
if (event.key === 'ArrowDown') y += step; | |||
$('#dev-tools-decoration-x').val(Math.round(x)); | |||
$('#dev-tools-decoration-y').val(Math.round(y)); | |||
updateDecorationPreview(true); | updateDecorationPreview(true); | ||
}); | }); | ||
} | } | ||
function | function updateDecorationPreview(silent) { | ||
var | var entry = readDecorationForm(); | ||
var | var target = getDecorationTargetElement(entry); | ||
var img = decorationEditor.preview; | |||
if (! | if (!entry.src) { | ||
if (!silent) setStatus('장식 이미지 파일명 또는 URL을 입력하세요.', 'error'); | |||
return false; | |||
} | |||
if (!target) { | |||
if (!silent) setStatus('대상 컨테이너를 찾지 못했습니다: ' + entry.target, 'error'); | |||
return false; | |||
} | |||
if (!img || decorationEditor.target !== target) { | |||
removeDecorationPreview(); | |||
if (! | img = document.createElement('img'); | ||
img.className = 'wiki-decoration dev-tools-decoration-preview'; | |||
img.setAttribute('aria-hidden', 'true'); | |||
img.setAttribute('alt', ''); | |||
img.setAttribute('tabindex', '0'); | |||
img.draggable = false; | |||
target.appendChild(img); | |||
decorationEditor.preview = img; | |||
decorationEditor.target = target; | |||
} | } | ||
ensureDecorationHost(target); | |||
img.src = normalizeDecorationSrc(entry.src); | |||
img.style.left = entry.x + 'px'; | |||
img.style.top = entry.y + 'px'; | |||
img.style.width = entry.width + 'px'; | |||
img.style.height = entry.height ? entry.height + 'px' : 'auto'; | |||
img.style.opacity = String(entry.opacity); | |||
img.style.zIndex = String(entry.zIndex); | |||
img.style.pointerEvents = 'auto'; | |||
img.style.cursor = 'move'; | |||
img.style.touchAction = 'none'; | |||
img.setAttribute('data-decoration-id', entry.id); | |||
if (entry.era) img.setAttribute('data-decoration-era', entry.era); | |||
else img.removeAttribute('data-decoration-era'); | |||
if (entry.continent) img.setAttribute('data-decoration-continent', entry.continent); | |||
else img.removeAttribute('data-decoration-continent'); | |||
localStorage.setItem('dev-tools-decoration-last-src', entry.src); | |||
if (img.focus) { | |||
try { img.focus({ preventScroll: true }); } catch (err) { img.focus(); } | |||
} | |||
decorationEditor.active = true; | |||
bindDecorationPreviewDrag(); | |||
return true; | |||
} | } | ||
function copyDecorationJson() { | |||
var entry = readDecorationForm(); | |||
var text = JSON.stringify(entry, null, 2); | |||
var textarea; | |||
if (navigator.clipboard && navigator.clipboard.writeText) { | |||
navigator.clipboard.writeText(text).then(function () { | |||
setStatus('장식 JSON 복사 완료', 'ok'); | |||
}).catch(function () { | |||
setStatus('클립보드 복사 실패', 'error'); | |||
}); | |||
return; | |||
} | |||
textarea = document.createElement('textarea'); | |||
textarea.value = text; | |||
textarea.style.position = 'fixed'; | |||
textarea.style.left = '-9999px'; | |||
document.body.appendChild(textarea); | |||
textarea.focus(); | |||
textarea.select(); | |||
document.execCommand('copy'); | |||
textarea.remove(); | |||
setStatus('장식 JSON 복사 완료', 'ok'); | |||
} | |||
function targetActiveDecorationContinent() { | |||
var era = getActiveDecorationEra(); | |||
var continent = getActiveDecorationContinent(); | |||
var selector = getActiveDecorationScopeSelector(); | |||
refreshDecorationScopeReadout(); | |||
setStatus(' | if (!era && !continent) { | ||
setStatus('활성 시대/대륙을 찾지 못했습니다.', 'error'); | |||
return; | |||
} | |||
$('#dev-tools-decoration-era').val(era || ''); | |||
$('#dev-tools-decoration-continent').val(continent || ''); | |||
$('#dev-tools-decoration-target').val(selector || getDefaultDecorationScopedTarget()); | |||
if (decorationEditor.active) updateDecorationPreview(true); | |||
setStatus('장식 scope 지정: ' + (era || 'PAGE') + (continent ? (' / ' + continent) : ''), 'ok'); | |||
} | } | ||
function | function saveDecorationEditor() { | ||
var | var entry = readDecorationForm(); | ||
if (! | if (!entry.src) { | ||
setStatus(' | setStatus('장식 이미지 파일명 또는 URL을 입력하세요.', 'error'); | ||
return; | return; | ||
} | } | ||
withApi(function (api) { | withApi(function (api) { | ||
setButtonsDisabled(true); | |||
setStatus('장식 JSON 저장 중...'); | |||
fetchDecorationRegistry(api).then(function (registry) { | |||
var nextRegistry = registry && typeof registry === 'object' && !Array.isArray(registry) ? registry : { version: 1, decorations: [] }; | |||
var decorations = getDecorationRegistryList(nextRegistry); | |||
var replaced = false; | |||
decorations.forEach(function (item, index) { | |||
if (item && item.id === entry.id) { | |||
decorations[index] = entry; | |||
replaced = true; | |||
} | } | ||
}); | |||
if (!replaced) decorations.push(entry); | |||
nextRegistry.version = nextRegistry.version || 1; | |||
nextRegistry.updatedAt = new Date().toISOString(); | |||
return api.postWithToken('csrf', { | |||
action: 'edit', | |||
if ( | title: DECORATIONS_TITLE, | ||
text: JSON.stringify(nextRegistry, null, 2) + '\n', | |||
summary: 'Update decoration: ' + entry.id, | |||
contentmodel: 'json', | |||
format: 'json', | |||
formatversion: 2 | |||
}); | |||
}).then(function () { | |||
setStatus('장식 JSON 저장 완료', 'ok'); | |||
if (window.CLBI_DECORATIONS && typeof window.CLBI_DECORATIONS.reload === 'function') { | |||
window.CLBI_DECORATIONS.reload(); | |||
} | |||
updateDecorationPreview(true); | |||
}).catch(function (err) { | }).catch(function (err) { | ||
setStatus('장식 저장 실패: ' + (err && err.message ? err.message : err), 'error'); | |||
}).always(function () { | |||
setButtonsDisabled(false); | setButtonsDisabled(false); | ||
}); | }); | ||
}, function () { | }, function () { | ||
| 2,910번째 줄: | 2,926번째 줄: | ||
} | } | ||
/* ----------------------------------------- | |||
Decoration layer editor v2 | |||
return; | ----------------------------------------- */ | ||
function decoClone(value) { | |||
try { | |||
return JSON.parse(JSON.stringify(value || {})); | |||
} catch (err) { | |||
return {}; | |||
} | } | ||
} | |||
function decoCurrentPageKey() { | |||
var raw = String(mw.config.get('wgPageName') || '').trim(); | |||
var normalized = normalizePageName(raw); | |||
return normalized || raw || '대문'; | |||
} | |||
function decoNormalizePage(value) { | |||
return normalizePageName(value || ''); | |||
} | |||
function decoMatchesCurrentPage(entry) { | |||
var page = decoCurrentPageKey(); | |||
var underscored = page.replace(/ /g, '_'); | |||
var pages = entry && entry.pages; | |||
var target = entry && entry.page; | |||
var i; | |||
if (!entry || typeof entry !== 'object') return false; | |||
if (entry.global === true || entry.global === 'true' || entry.global === 1 || entry.global === '1') return true; | |||
if (!target && !pages) return true; | |||
if ( | if (Array.isArray(pages)) { | ||
for (i = 0; i < pages.length; i += 1) { | |||
if (decoNormalizePage(pages[i]) === page || String(pages[i] || '') === underscored) return true; | |||
} | |||
} | |||
target = decoNormalizePage(target); | |||
return target === page || target === underscored; | |||
} | |||
function decoRegistryList(registry) { | |||
if (!registry || typeof registry !== 'object') return []; | |||
if (Array.isArray(registry)) return registry; | |||
if (!Array.isArray(registry.decorations)) registry.decorations = []; | |||
return registry.decorations; | |||
} | |||
function decoMakeRegistry(raw) { | |||
var registry = raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : { version: 1, decorations: [] }; | |||
if (!Array.isArray(registry.decorations)) registry.decorations = []; | |||
if (!registry.version) registry.version = 1; | |||
return registry; | |||
} | |||
function decoMakeId(label) { | |||
var base = String(label || decoCurrentPageKey() || 'decoration') | |||
.replace(/[^a-zA-Z0-9가-힣_-]+/g, '-') | |||
.replace(/^-+|-+$/g, '') || 'decoration'; | |||
return base + '-' + Date.now(); | |||
} | |||
function decoIsGridEnabled() { | |||
return localStorage.getItem('dev-tools-decoration-grid-enabled') !== 'false'; | |||
} | |||
function decoSetGridEnabled(enabled) { | |||
localStorage.setItem('dev-tools-decoration-grid-enabled', enabled ? 'true' : 'false'); | |||
$('#dev-tools-decoration-grid-toggle') | |||
.attr('data-grid', enabled ? '1' : '0') | |||
.text(enabled ? 'GRID ON' : 'GRID OFF'); | |||
renderDecorationGridOverlay(); | |||
} | |||
function decorationLayerList() { | |||
return decoRegistryList(decorationEditor.workingRegistry || decorationEditor.registry || { decorations: [] }); | |||
} | |||
function decorationVisibleLayerList() { | |||
return decorationLayerList().filter(function (entry) { | |||
return decoMatchesCurrentPage(entry); | |||
}).sort(function (a, b) { | |||
return (Number(b.zIndex) || 0) - (Number(a.zIndex) || 0); | |||
}); | |||
} | |||
function getDecorationSelectedLayer() { | |||
var list = decorationLayerList(); | |||
var id = decorationEditor.selectedId; | |||
var i; | |||
for (i = 0; i < list.length; i += 1) { | |||
if (list[i] && list[i].id === id) return list[i]; | |||
} | |||
return null; | |||
} | } | ||
function setDecorationEditorModeUi() { | |||
var editing = !!decorationEditor.editMode; | |||
var hasSelected = !!getDecorationSelectedLayer(); | |||
$('.dev-tools-decoration-section').toggleClass('is-editing', editing); | |||
$('body').toggleClass('clbi-decoration-edit-mode', editing); | |||
$('#dev-tools-decoration-mode').text(editing ? 'EDIT MODE' : 'VIEW MODE'); | |||
$('#dev-tools-decoration-edit-toggle').text(editing ? 'EDITING' : 'EDIT MODE').toggleClass('is-active', editing); | |||
$('#dev-tools-decoration-save, #dev-tools-decoration-cancel, #dev-tools-decoration-add').prop('disabled', !editing); | |||
$('#dev-tools-decoration-delete, #dev-tools-decoration-target-continent, #dev-tools-decoration-center, #dev-tools-decoration-fit-width').prop('disabled', !editing || !hasSelected); | |||
$('#dev-tools-decoration-label, #dev-tools-decoration-src, #dev-tools-decoration-target, #dev-tools-decoration-era, #dev-tools-decoration-continent, #dev-tools-decoration-x, #dev-tools-decoration-y, #dev-tools-decoration-width, #dev-tools-decoration-height, #dev-tools-decoration-opacity, #dev-tools-decoration-z').prop('disabled', !editing || !hasSelected); | |||
} | |||
function renderDecorationLayerList() { | |||
var list = decorationVisibleLayerList(); | |||
var html; | |||
$('#dev-tools-decoration-page').text('PAGE: ' + decoCurrentPageKey()); | |||
refreshDecorationContinentReadout(); | |||
if (!list.length) { | |||
html = '<div class="dev-tools-decoration-empty">이 문서에 표시되는 장식 레이어가 없습니다.</div>'; | |||
} else { | |||
html = list.map(function (entry) { | |||
var selected = entry.id === decorationEditor.selectedId; | |||
var title = entry.label || entry.name || entry.id || 'untitled'; | |||
var meta = []; | |||
if (entry.era) meta.push(String(entry.era)); | |||
if (entry.continent) meta.push(String(entry.continent)); | |||
meta.push('z ' + (entry.zIndex == null ? 0 : entry.zIndex)); | |||
return '' + | |||
'<button type="button" class="dev-tools-decoration-layer' + (selected ? ' is-selected' : '') + '" data-decoration-id="' + escapeHtml(entry.id || '') + '">' + | |||
'<span class="dev-tools-decoration-layer-name">' + escapeHtml(title) + '</span>' + | |||
'<span class="dev-tools-decoration-layer-meta">' + escapeHtml(meta.join(' / ')) + '</span>' + | |||
'</button>'; | |||
}).join(''); | |||
} | |||
$('#dev-tools-decoration-layer-list').html(html); | |||
setDecorationEditorModeUi(); | |||
} | |||
function writeDecorationLayerForm(entry) { | |||
entry = entry || {}; | |||
$('#dev-tools-decoration-id').val(entry.id || ''); | |||
$('#dev-tools-decoration-label').val(entry.label || entry.name || entry.id || ''); | |||
$('#dev-tools-decoration-target').val(entry.target || getDefaultDecorationTarget()); | |||
$('#dev-tools-decoration-era').val(entry.era || ''); | |||
$('#dev-tools-decoration-continent').val(entry.continent || ''); | |||
$('#dev-tools-decoration-src').val(entry.src || ''); | |||
$('#dev-tools-decoration-x').val(entry.x == null ? 0 : Math.round(Number(entry.x) || 0)); | |||
$('#dev-tools-decoration-y').val(entry.y == null ? 0 : Math.round(Number(entry.y) || 0)); | |||
$('#dev-tools-decoration-width').val(entry.width == null ? 1372 : Math.max(1, Math.round(Number(entry.width) || 1372))); | |||
$('#dev-tools-decoration-height').val(entry.height == null ? 'auto' : Math.max(1, Math.round(Number(entry.height) || 1))); | |||
$('#dev-tools-decoration-opacity').val(entry.opacity == null ? 1 : Math.max(0, Math.min(1, Number(entry.opacity) || 0))); | |||
$('#dev-tools-decoration-z').val(entry.zIndex == null ? 0 : Math.round(Number(entry.zIndex) || 0)); | |||
refreshDecorationScopeReadout(); | |||
setDecorationEditorModeUi(); | |||
} | } | ||
function | function readDecorationLayerForm() { | ||
var | var height = readDecorationHeight(); | ||
var label = String($('#dev-tools-decoration-label').val() || '').trim(); | |||
var entry = { | |||
id: String($('#dev-tools-decoration-id').val() || '').trim() || decoMakeId(label), | |||
label: label, | |||
page: decoCurrentPageKey(), | |||
target: String($('#dev-tools-decoration-target').val() || '').trim() || getDefaultDecorationTarget(), | |||
era: String($('#dev-tools-decoration-era').val() || '').trim(), | |||
continent: String($('#dev-tools-decoration-continent').val() || '').trim(), | |||
src: String($('#dev-tools-decoration-src').val() || '').trim(), | |||
x: Math.round(readDecorationNumber('#dev-tools-decoration-x', 0)), | |||
y: Math.round(readDecorationNumber('#dev-tools-decoration-y', 0)), | |||
width: Math.max(1, Math.round(readDecorationNumber('#dev-tools-decoration-width', 1372))), | |||
opacity: Math.max(0, Math.min(1, readDecorationNumber('#dev-tools-decoration-opacity', 1))), | |||
zIndex: Math.round(readDecorationNumber('#dev-tools-decoration-z', 0)), | |||
pointerEvents: 'none', | |||
enabled: true | |||
}; | |||
if (! | if (!entry.label) delete entry.label; | ||
if (height) entry.height = height; | |||
if (!entry.era) delete entry.era; | |||
if (!entry.continent) delete entry.continent; | |||
return entry; | |||
} | |||
function replaceSelectedDecorationFromForm() { | |||
var entry; | |||
var list; | |||
var i; | |||
if (!decorationEditor.editMode || !decorationEditor.selectedId) return null; | |||
entry = readDecorationLayerForm(); | |||
list = decorationLayerList(); | |||
for (i = 0; i < list.length; i += 1) { | |||
if (list[i] && list[i].id === decorationEditor.selectedId) { | |||
list[i] = entry; | |||
decorationEditor.selectedId = entry.id; | |||
return entry; | |||
} | |||
} | } | ||
return null; | |||
} | |||
function selectDecorationLayer(id, silent) { | |||
var list = decorationLayerList(); | |||
var i; | |||
var entry = null; | |||
id = String(id || ''); | |||
for (i = 0; i < list.length; i += 1) { | |||
if (list[i] && list[i].id === id) { | |||
} | entry = list[i]; | ||
break; | |||
} | |||
} | |||
if (!entry) return false; | |||
decorationEditor.selectedId = entry.id; | |||
writeDecorationLayerForm(entry); | |||
renderDecorationLayerList(); | |||
markDecorationSelectedPreview(); | |||
renderDecorationGridOverlay(); | |||
if (!silent) setStatus('레이어 선택: ' + (entry.label || entry.id), 'ok'); | |||
return true; | |||
} | } | ||
function | function selectFirstVisibleDecorationLayer() { | ||
var | var list = decorationVisibleLayerList(); | ||
if (list.length) { | |||
selectDecorationLayer(list[0].id, true); | |||
} else { | |||
decorationEditor.selectedId = ''; | |||
writeDecorationLayerForm({}); | |||
renderDecorationLayerList(); | |||
} | } | ||
} | |||
function loadDecorationLayerRegistry(callback) { | |||
withApi(function (api) { | |||
setStatus(' | fetchDecorationRegistry(api).then(function (registry) { | ||
decorationEditor.registry = decoMakeRegistry(registry); | |||
if (!decorationEditor.editMode) { | |||
decorationEditor.workingRegistry = decoClone(decorationEditor.registry); | |||
} | |||
renderDecorationLayerList(); | |||
if (!getDecorationSelectedLayer()) selectFirstVisibleDecorationLayer(); | |||
if (typeof callback === 'function') callback(decorationEditor.registry); | |||
}).catch(function (err) { | |||
setStatus('장식 목록 로드 실패: ' + (err && err.message ? err.message : err), 'error'); | |||
}); | |||
}, function () { | |||
setStatus('mediawiki.api 로드 실패', 'error'); | |||
}); | |||
} | |||
function writeDecorationDefaults() { | |||
decoSetGridEnabled(decoIsGridEnabled()); | |||
writeDecorationLayerForm({ | |||
id: decoMakeId('decoration'), | |||
page: decoCurrentPageKey(), | |||
target: getDefaultDecorationTarget(), | |||
src: localStorage.getItem('dev-tools-decoration-last-src') || '', | |||
x: 0, | |||
y: 0, | |||
width: 1372, | |||
opacity: 1, | |||
zIndex: 0 | |||
}); | |||
loadDecorationLayerRegistry(); | |||
} | } | ||
function | function clearDecorationWorkPreviews() { | ||
Array.prototype.forEach.call(document.querySelectorAll('.dev-tools-decoration-work-preview, .dev-tools-decoration-grid-overlay'), function (node) { | |||
if (node.parentNode) node.parentNode.removeChild(node); | |||
}); | |||
decorationEditor.previewMap = {}; | |||
decorationEditor.gridTarget = null; | |||
} | |||
function applyDecorationLayerStyle(img, entry) { | |||
var h = Number(entry.height); | |||
img.src = normalizeDecorationSrc(entry.src); | |||
img.style.left = Math.round(Number(entry.x) || 0) + 'px'; | |||
img.style.top = Math.round(Number(entry.y) || 0) + 'px'; | |||
img.style.width = Math.max(1, Math.round(Number(entry.width) || 1)) + 'px'; | |||
img.style.height = Number.isFinite(h) && h > 0 ? Math.round(h) + 'px' : 'auto'; | |||
img.style.opacity = String(entry.opacity == null ? 1 : entry.opacity); | |||
img.style.zIndex = String(Math.round(Number(entry.zIndex) || 0)); | |||
img.style.pointerEvents = 'auto'; | |||
img.style.cursor = 'move'; | |||
img.style.touchAction = 'none'; | |||
img.setAttribute('data-decoration-id', entry.id || ''); | |||
} | |||
function renderDecorationWorkPreviews() { | |||
var list; | |||
var map = {}; | |||
var i; | |||
var entry; | |||
var target; | |||
var img; | |||
if (!decorationEditor.editMode) return; | |||
clearDecorationWorkPreviews(); | |||
list = decorationLayerList(); | |||
for (i = 0; i < list.length; i += 1) { | |||
entry = list[i]; | |||
if (!entry || entry.enabled === false || !entry.src || !decoMatchesCurrentPage(entry)) continue; | |||
target = getDecorationTargetElement(entry); | |||
if (!target) continue; | |||
ensureDecorationHost(target); | |||
img = document.createElement('img'); | |||
img.className = 'wiki-decoration dev-tools-decoration-work-preview'; | |||
img.setAttribute('aria-hidden', 'true'); | |||
img.setAttribute('alt', ''); | |||
img.setAttribute('tabindex', '0'); | |||
img.draggable = false; | |||
applyDecorationLayerStyle(img, entry); | |||
target.appendChild(img); | |||
map[entry.id] = img; | |||
} | |||
decorationEditor.previewMap = map; | |||
markDecorationSelectedPreview(); | |||
renderDecorationGridOverlay(); | |||
bindDecorationLayerPreviewDrag(); | |||
} | |||
function markDecorationSelectedPreview() { | |||
var map = decorationEditor.previewMap || {}; | |||
Object.keys(map).forEach(function (id) { | |||
if (map[id]) map[id].classList.toggle('is-selected', id === decorationEditor.selectedId); | |||
}); | |||
} | |||
return | function renderDecorationGridOverlay() { | ||
var entry; | |||
var target; | |||
var overlay; | |||
clearDecorationGridOverlay(); | |||
if (!decorationEditor.editMode || !decoIsGridEnabled()) return; | |||
entry = getDecorationSelectedLayer(); | |||
if (!entry) return; | |||
target = getDecorationTargetElement(entry); | |||
if (!target) return; | |||
ensureDecorationHost(target); | |||
overlay = document.createElement('div'); | |||
overlay.className = 'dev-tools-decoration-grid-overlay'; | |||
overlay.setAttribute('aria-hidden', 'true'); | |||
target.appendChild(overlay); | |||
decorationEditor.gridTarget = target; | |||
} | } | ||
function | function clearDecorationGridOverlay() { | ||
Array.prototype.forEach.call(document.querySelectorAll('.dev-tools-decoration-grid-overlay'), function (node) { | |||
if (node.parentNode) node.parentNode.removeChild(node); | |||
}); | |||
decorationEditor.gridTarget = null; | |||
} | } | ||
function | function getDecorationPreviewForSelected() { | ||
var | var map = decorationEditor.previewMap || {}; | ||
if ( | return map[decorationEditor.selectedId] || null; | ||
} | |||
function snapDecorationPosition(x, y, entry) { | |||
var target; | |||
var img; | |||
var targetW; | |||
var targetH; | |||
var imgW; | |||
var imgH; | |||
var centerX; | |||
var centerY; | |||
var threshold = 7; | |||
if (!decoIsGridEnabled()) return { x: x, y: y }; | |||
target = getDecorationTargetElement(entry); | |||
img = getDecorationPreviewForSelected(); | |||
if (!target || !img) return { x: x, y: y }; | |||
targetW = target.clientWidth || target.offsetWidth || 0; | |||
targetH = target.clientHeight || target.offsetHeight || 0; | |||
imgW = img.offsetWidth || Number(entry.width) || 0; | |||
imgH = img.offsetHeight || Number(entry.height) || 0; | |||
if (targetW > 0 && imgW > 0) { | |||
centerX = Math.round((targetW - imgW) / 2); | |||
if (Math.abs(x - centerX) <= threshold) x = centerX; | |||
} | |||
if (targetH > 0 && imgH > 0) { | |||
centerY = Math.round((targetH - imgH) / 2); | |||
if (Math.abs(y - centerY) <= threshold) y = centerY; | |||
} | } | ||
return | return { x: Math.round(x), y: Math.round(y) }; | ||
} | |||
function setDecorationLayerPosition(x, y) { | |||
var entry; | |||
var snapped; | |||
if (!decorationEditor.editMode) return; | |||
entry = getDecorationSelectedLayer(); | |||
if (!entry) return; | |||
snapped = snapDecorationPosition(x, y, entry); | |||
entry.x = snapped.x; | |||
entry.y = snapped.y; | |||
$('#dev-tools-decoration-x').val(entry.x); | |||
$('#dev-tools-decoration-y').val(entry.y); | |||
renderDecorationWorkPreviews(); | |||
} | } | ||
function | function bindDecorationLayerPreviewDrag() { | ||
var map = decorationEditor.previewMap || {}; | |||
Object.keys(map).forEach(function (id) { | |||
var img = map[id]; | |||
if (!img || img.getAttribute('data-dev-tools-layer-bound') === '1') return; | |||
var | img.setAttribute('data-dev-tools-layer-bound', '1'); | ||
img.addEventListener('pointerdown', function (event) { | |||
var entry; | |||
var target; | |||
if (!decorationEditor.editMode) return; | |||
selectDecorationLayer(id, true); | |||
entry = getDecorationSelectedLayer(); | |||
target = getDecorationTargetElement(entry); | |||
if (!entry || !target) return; | |||
event.preventDefault(); | |||
event.stopPropagation(); | |||
decorationEditor.drag = { | |||
id: id, | |||
pointerId: event.pointerId, | |||
startClientX: event.clientX, | |||
startClientY: event.clientY, | |||
startX: Number(entry.x) || 0, | |||
startY: Number(entry.y) || 0, | |||
axis: '' | |||
}; | |||
img.setPointerCapture && img.setPointerCapture(event.pointerId); | |||
img.classList.add('is-dragging'); | |||
}); | |||
img. | img.addEventListener('wheel', function (event) { | ||
var entry; | |||
var width; | |||
if (!decorationEditor.editMode || (!event.ctrlKey && !event.altKey)) return; | |||
event.preventDefault(); | |||
selectDecorationLayer(id, true); | |||
entry = getDecorationSelectedLayer(); | |||
if (!entry) return; | |||
width = Math.max(1, Math.round((Number(entry.width) || 1) + (event.deltaY < 0 ? 8 : -8) * (event.shiftKey ? 4 : 1))); | |||
entry.width = width; | |||
$('#dev-tools-decoration-width').val(width); | |||
renderDecorationWorkPreviews(); | |||
}, { passive: false }); | |||
}); | |||
} | } | ||
}); | |||
} | |||
function | function updateDecorationDragMove(event) { | ||
var | var drag = decorationEditor.drag; | ||
var | var entry; | ||
var | var target; | ||
var | var scale; | ||
var | var dx; | ||
var | var dy; | ||
var x; | var x; | ||
var y; | var y; | ||
if (!drag || !decorationEditor.editMode) return; | |||
entry = getDecorationSelectedLayer(); | |||
target = getDecorationTargetElement(entry); | |||
if (!entry || !target) return; | |||
scale = getDecorationTargetScale(target); | |||
dx = (event.clientX - drag.startClientX) / (scale.x || 1); | |||
dy = (event.clientY - drag.startClientY) / (scale.y || 1); | |||
if (event.shiftKey) { | |||
if (!drag.axis && Math.max(Math.abs(dx), Math.abs(dy)) >= 4) { | |||
drag.axis = Math.abs(dx) >= Math.abs(dy) ? 'x' : 'y'; | |||
} | } | ||
if (drag.axis === 'x') dy = 0; | |||
if (drag.axis === 'y') dx = 0; | |||
} else { | |||
drag.axis = ''; | |||
} | } | ||
x = drag.startX + dx; | |||
y = drag.startY + dy; | |||
setDecorationLayerPosition(x, y); | |||
} | |||
if ( | function stopDecorationDrag() { | ||
var map = decorationEditor.previewMap || {}; | |||
Object.keys(map).forEach(function (id) { | |||
if (map[id]) map[id].classList.remove('is-dragging'); | |||
}); | |||
decorationEditor.drag = null; | |||
} | } | ||
function | function enterDecorationEditMode() { | ||
if (decorationEditor.editMode) return; | |||
setButtonsDisabled(true); | |||
setStatus('장식 에딧 모드 준비 중...'); | |||
withApi(function (api) { | |||
fetchDecorationRegistry(api).then(function (registry) { | |||
decorationEditor.registry = decoMakeRegistry(registry); | |||
decorationEditor.savedRegistry = decoClone(decorationEditor.registry); | |||
decorationEditor.workingRegistry = decoClone(decorationEditor.registry); | |||
decorationEditor.editMode = true; | |||
if (window.CLBI_DECORATIONS && typeof window.CLBI_DECORATIONS.clear === 'function') { | |||
window.CLBI_DECORATIONS.clear(document); | |||
} | |||
if (!getDecorationSelectedLayer()) selectFirstVisibleDecorationLayer(); | |||
renderDecorationLayerList(); | |||
renderDecorationWorkPreviews(); | |||
setDecorationEditorModeUi(); | |||
setStatus('DECORATION EDIT MODE', 'ok'); | |||
}).catch(function (err) { | |||
setStatus('장식 에딧 모드 시작 실패: ' + (err && err.message ? err.message : err), 'error'); | |||
}).always(function () { | |||
setButtonsDisabled(false); | |||
setDecorationEditorModeUi(); | |||
}); | |||
}, function () { | |||
setButtonsDisabled(false); | |||
setDecorationEditorModeUi(); | |||
setStatus('mediawiki.api 로드 실패', 'error'); | |||
}); | |||
} | |||
if ( | function cancelDecorationLayerEditor() { | ||
if (!decorationEditor.editMode) return; | |||
decorationEditor.workingRegistry = decoClone(decorationEditor.savedRegistry || decorationEditor.registry || { decorations: [] }); | |||
decorationEditor.editMode = false; | |||
clearDecorationWorkPreviews(); | |||
setDecorationEditorModeUi(); | |||
renderDecorationLayerList(); | |||
if (window.CLBI_DECORATIONS && typeof window.CLBI_DECORATIONS.reload === 'function') { | |||
window.CLBI_DECORATIONS.reload(); | |||
} | } | ||
setStatus('장식 편집 취소 / 저장값 복구', 'ok'); | |||
} | |||
function toggleDecorationEditMode() { | |||
if ( | if (decorationEditor.editMode) { | ||
cancelDecorationLayerEditor(); | |||
} else { | |||
enterDecorationEditMode(); | |||
} | |||
} | |||
return | function addDecorationLayer() { | ||
var list; | |||
var entry; | |||
var target; | |||
if (!decorationEditor.editMode) return; | |||
list = decorationLayerList(); | |||
target = getDefaultDecorationScopedTarget ? getDefaultDecorationScopedTarget() : getDefaultDecorationTarget(); | |||
entry = { | |||
id: decoMakeId('decoration'), | |||
label: 'Decoration ' + (list.length + 1), | |||
page: decoCurrentPageKey(), | |||
target: target || getDefaultDecorationTarget(), | |||
era: getActiveDecorationEra() || '', | |||
continent: getActiveDecorationContinent() || '', | |||
src: localStorage.getItem('dev-tools-decoration-last-src') || '', | |||
x: 0, | |||
y: 0, | |||
width: Math.max(1, (document.querySelector(target || getDefaultDecorationTarget()) || {}).clientWidth || 1372), | |||
opacity: 1, | |||
zIndex: list.length, | |||
pointerEvents: 'none', | |||
enabled: true | |||
}; | |||
if (!entry.era) delete entry.era; | |||
if (!entry.continent) delete entry.continent; | |||
list.push(entry); | |||
decorationEditor.selectedId = entry.id; | |||
writeDecorationLayerForm(entry); | |||
renderDecorationLayerList(); | |||
renderDecorationWorkPreviews(); | |||
setStatus('새 장식 레이어 추가', 'ok'); | |||
} | |||
if ( | function deleteDecorationLayer() { | ||
var list; | |||
var id; | |||
var next; | |||
if (!decorationEditor.editMode || !decorationEditor.selectedId) return; | |||
if (!window.confirm('선택한 장식 레이어를 삭제할까요? 저장 전까지는 CANCEL로 되돌릴 수 있습니다.')) return; | |||
list = decorationLayerList(); | |||
id = decorationEditor.selectedId; | |||
decorationEditor.workingRegistry.decorations = list.filter(function (entry) { | |||
return !entry || entry.id !== id; | |||
}); | |||
next = decorationVisibleLayerList()[0]; | |||
decorationEditor.selectedId = next ? next.id : ''; | |||
if (next) writeDecorationLayerForm(next); | |||
else writeDecorationLayerForm({}); | |||
renderDecorationLayerList(); | |||
renderDecorationWorkPreviews(); | |||
setStatus('레이어 삭제 대기 / SAVE 필요', 'ok'); | |||
} | |||
function updateDecorationLayerFromForm() { | |||
var entry; | |||
if (!decorationEditor.editMode) return; | |||
entry = replaceSelectedDecorationFromForm(); | |||
if (!entry) return; | |||
if (entry.src) localStorage.setItem('dev-tools-decoration-last-src', entry.src); | |||
renderDecorationLayerList(); | |||
renderDecorationWorkPreviews(); | |||
} | |||
function centerDecorationLayer() { | |||
var entry; | |||
var target; | |||
var img; | |||
var x; | |||
var y; | |||
if (!decorationEditor.editMode) return; | |||
updateDecorationLayerFromForm(); | |||
entry = getDecorationSelectedLayer(); | |||
target = getDecorationTargetElement(entry); | |||
img = getDecorationPreviewForSelected(); | |||
if (!entry || !target) return; | |||
x = Math.round(((target.clientWidth || target.offsetWidth || 0) - (img ? img.offsetWidth : Number(entry.width) || 0)) / 2); | |||
y = Math.round(((target.clientHeight || target.offsetHeight || 0) - (img ? img.offsetHeight : Number(entry.height) || 0)) / 2); | |||
setDecorationLayerPosition(x, y); | |||
} | |||
function fitDecorationLayerWidth() { | |||
var entry; | |||
var target; | |||
if (!decorationEditor.editMode) return; | |||
updateDecorationLayerFromForm(); | |||
entry = getDecorationSelectedLayer(); | |||
target = getDecorationTargetElement(entry); | |||
if (!entry || !target) return; | |||
entry.x = 0; | |||
entry.width = Math.max(1, target.clientWidth || target.offsetWidth || Number(entry.width) || 1); | |||
$('#dev-tools-decoration-x').val(0); | |||
$('#dev-tools-decoration-width').val(entry.width); | |||
renderDecorationWorkPreviews(); | |||
} | |||
function saveDecorationEditor() { | |||
var registry; | |||
if (!decorationEditor.editMode) return; | |||
updateDecorationLayerFromForm(); | |||
registry = decoMakeRegistry(decoClone(decorationEditor.workingRegistry || { decorations: [] })); | |||
registry.updatedAt = new Date().toISOString(); | |||
withApi(function (api) { | |||
setButtonsDisabled(true); | |||
setStatus('장식 레이어 저장 중...'); | |||
api.postWithToken('csrf', { | |||
action: 'edit', | |||
title: DECORATIONS_TITLE, | |||
text: JSON.stringify(registry, null, 2) + '\n', | |||
summary: 'Update decoration layers', | |||
contentmodel: 'json', | |||
format: 'json', | |||
formatversion: 2 | |||
}).then(function () { | |||
decorationEditor.registry = decoClone(registry); | |||
decorationEditor.savedRegistry = decoClone(registry); | |||
decorationEditor.workingRegistry = decoClone(registry); | |||
decorationEditor.editMode = false; | |||
clearDecorationWorkPreviews(); | |||
renderDecorationLayerList(); | |||
setDecorationEditorModeUi(); | |||
if (window.CLBI_DECORATIONS && typeof window.CLBI_DECORATIONS.reload === 'function') { | |||
window.CLBI_DECORATIONS.reload(); | |||
} | |||
setStatus('장식 레이어 저장 완료', 'ok'); | |||
}).catch(function (err) { | |||
setStatus('장식 레이어 저장 실패: ' + (err && err.message ? err.message : err), 'error'); | |||
}).always(function () { | |||
setButtonsDisabled(false); | |||
setDecorationEditorModeUi(); | |||
}); | |||
}, function () { | |||
setButtonsDisabled(false); | |||
setDecorationEditorModeUi(); | |||
setStatus('mediawiki.api 로드 실패', 'error'); | |||
}); | |||
} | |||
function targetActiveDecorationContinent() { | |||
var entry; | |||
var era = getActiveDecorationEra(); | |||
var continent = getActiveDecorationContinent(); | |||
var selector = getActiveDecorationScopeSelector(); | |||
if (!decorationEditor.editMode) return; | |||
entry = getDecorationSelectedLayer(); | |||
if (!entry) return; | |||
$('#dev-tools-decoration-era').val(era || ''); | |||
$('#dev-tools-decoration-continent').val(continent || ''); | |||
$('#dev-tools-decoration-target').val(selector || getDefaultDecorationTarget()); | |||
updateDecorationLayerFromForm(); | |||
setStatus('대상 scope 지정: ' + (era || 'PAGE') + (continent ? (' / ' + continent) : ''), 'ok'); | |||
} | } | ||
function | function refreshDecorationContinentReadout() { | ||
var era = getActiveDecorationEra(); | |||
var continent = getActiveDecorationContinent(); | |||
$('#dev-tools-decoration-continent-readout').text('ERA: ' + (era || 'NONE') + ' / CONTINENT: ' + (continent || 'NONE')); | |||
} | |||
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({ | return api.get({ | ||
action: 'query', | action: 'query', | ||
titles: normalizeApiTitle(fromTitle), | |||
redirects: 1, | |||
prop: 'info', | |||
format: 'json', | format: 'json', | ||
formatversion: 2 | formatversion: 2 | ||
}).then(function (data) { | }).then(function (data) { | ||
var | var redirects = data && data.query ? data.query.redirects : []; | ||
var | var item = redirects && redirects.length ? redirects[0] : null; | ||
if ( | if (item && item.to) return item.to; | ||
return ''; | |||
}).catch(function () { | |||
return ''; | |||
return | |||
}); | }); | ||
} | } | ||
function deleteRenameRedirectPage(api, fromTitle, expectedToTitle, reason) { | |||
fromTitle = normalizeMoveTitle(fromTitle); | |||
expectedToTitle = normalizeMoveTitle(expectedToTitle); | |||
if (!fromTitle) return Promise.resolve({ deleted: false, skipped: 'empty' }); | |||
return resolveRenameRedirectTarget(api, fromTitle).then(function (redirectTo) { | |||
redirectTo = normalizeMoveTitle(redirectTo); | |||
if (!redirectTo) { | |||
return { deleted: false, skipped: 'not_redirect' }; | |||
} | |||
if (expectedToTitle && normalizeMoveCompare(redirectTo) !== normalizeMoveCompare(expectedToTitle)) { | |||
throw new Error('기존 이름이 새 이름이 아니라 다른 대상으로 redirect됩니다: ' + redirectTo); | |||
} | |||
} | setStatus('기존 redirect 문서 제거 중... ' + fromTitle); | ||
return | |||
return api.postWithToken('csrf', { | |||
action: 'delete', | |||
title: fromTitle, | |||
reason: reason || ('이름 변경 후 남은 redirect 제거: ' + fromTitle + ' → ' + redirectTo), | |||
format: 'json', | |||
formatversion: 2, | |||
errorformat: 'plaintext' | |||
}).then(function () { | |||
return { deleted: true, target: redirectTo }; | |||
}); | }); | ||
}); | }); | ||
} | } | ||
function | function runRenameReferenceCleanupOnly() { | ||
var fromTitle = normalizeMoveTitle($('#dev-tools-rename-from').val()); | |||
var toTitle = normalizeMoveTargetTitle(fromTitle, $('#dev-tools-rename-to').val()); | |||
var shouldDeleteOldRedirect = $('#dev-tools-rename-delete-old').prop('checked'); | |||
var reason = String($('#dev-tools-rename-reason').val() || '이름 변경').trim(); | |||
if (!fromTitle) { | |||
setStatus('현재 이름이 비어 있습니다.', 'error'); | |||
if (! | |||
setStatus(' | |||
return; | return; | ||
} | } | ||
setButtonsDisabled(true); | setButtonsDisabled(true); | ||
setStatus(' | setStatus('참조/리스트 정리 준비 중...'); | ||
withApi(function (api) { | withApi(function (api) { | ||
mapLimit(titles, 1, function (title, index) { | 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) { | |||
if (!shouldDeleteOldRedirect) { | |||
return { movedTo: resolvedToTitle, referenceResult: referenceResult, deleteResult: null }; | |||
} | |||
return deleteRenameRedirectPage(api, fromTitle, resolvedToTitle, '이름 변경 후 기존 redirect 제거: ' + fromTitle + ' → ' + resolvedToTitle).then(function (deleteResult) { | |||
return { movedTo: resolvedToTitle, referenceResult: referenceResult, deleteResult: deleteResult }; | |||
}); | |||
}); | |||
}).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 + '개'; | |||
if (result.deleteResult && result.deleteResult.deleted) message += ' / 기존 redirect 삭제'; | |||
if (result.deleteResult && result.deleteResult.skipped === 'not_redirect') message += ' / 기존 redirect 없음'; | |||
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 shouldDeleteOldRedirect = $('#dev-tools-rename-delete-old').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기존 이름 redirect를 남깁니다.' : '\n\n기존 이름 redirect를 남기지 않습니다.'))) { | |||
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); | |||
var cleanupChain = shouldUpdateReferences ? updateRenameReferences(api, fromTitle, movedTo || toTitle) : Promise.resolve(null); | |||
return cleanupChain.then(function (referenceResult) { | |||
if (keepLegacy || !shouldDeleteOldRedirect) { | |||
return { movedTo: movedTo, referenceResult: referenceResult, deleteResult: null }; | |||
} | |||
return deleteRenameRedirectPage(api, fromTitle, movedTo || toTitle, '이름 변경 후 기존 redirect 제거: ' + fromTitle + ' → ' + (movedTo || toTitle)).then(function (deleteResult) { | |||
return { movedTo: movedTo, referenceResult: referenceResult, deleteResult: deleteResult }; | |||
}); | |||
}); | |||
}).then(function (result) { | |||
var movedTo = result && result.movedTo ? result.movedTo : toTitle; | |||
var referenceResult = result && result.referenceResult; | |||
var deleteResult = result && result.deleteResult; | |||
var message = '이름 변경 완료: ' + movedTo; | |||
if (referenceResult) { | |||
message += ' / 참조 ' + referenceResult.changed + '개 정리'; | |||
if (referenceResult.failed) message += ' / 실패 ' + referenceResult.failed + '개'; | |||
} | |||
if (deleteResult && deleteResult.deleted) message += ' / 기존 redirect 삭제'; | |||
if (deleteResult && deleteResult.skipped === 'not_redirect') message += ' / 기존 redirect 없음'; | |||
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 normalizeSvgFileTitle(value) { | |||
var title = String(value || '') | |||
.replace(/_/g, ' ') | |||
.replace(/^\s+|\s+$/g, ''); | |||
if (!title) return ''; | |||
if (/^(file|파일):/i.test(title)) return title.replace(/^(file|파일):\s*/i, 'File:'); | |||
return 'File:' + title; | |||
} | |||
function svgFileUploadName(title) { | |||
return normalizeSvgFileTitle(title).replace(/^File:/i, '').trim(); | |||
} | |||
function parseSvgTitleList(value) { | |||
var seen = {}; | |||
var list = []; | |||
String(value || '').split(/[\n,]+/).forEach(function (part) { | |||
var title = normalizeSvgFileTitle(part); | |||
var key = title.toLowerCase(); | |||
if (!title || seen[key]) return; | |||
seen[key] = true; | |||
list.push(title); | |||
}); | |||
return list; | |||
} | |||
function renderSvgNormalizeLog(items) { | |||
var html; | |||
if (!items || !items.length) { | |||
$('#dev-tools-svg-list').empty(); | |||
return; | |||
} | |||
html = items.map(function (item) { | |||
var cls = item.ok ? 'dev-tools-package-row' : 'dev-tools-package-row dev-tools-package-skipped'; | |||
var status = item.message || ''; | |||
return '<div class="' + cls + '">' + | |||
'<span class="dev-tools-package-source">' + escapeHtml(item.title || '') + '</span>' + | |||
'<span class="dev-tools-package-arrow">' + (item.ok ? '✓' : '×') + '</span>' + | |||
'<span class="dev-tools-package-target">' + escapeHtml(status) + '</span>' + | |||
'</div>'; | |||
}).join(''); | |||
$('#dev-tools-svg-list').html(html); | |||
} | |||
function fillCurrentSvgFileTitle() { | |||
var ns = Number(mw.config.get('wgNamespaceNumber')); | |||
var title = mw.config.get('wgPageName') || ''; | |||
if (ns !== 6 || !title) { | |||
setStatus('현재 문서가 파일 문서가 아닙니다.', 'error'); | |||
return; | |||
} | |||
title = title.replace(/_/g, ' '); | |||
if (!/\.svg$/i.test(title)) { | |||
setStatus('현재 파일이 SVG가 아닙니다.', 'error'); | |||
return; | |||
} | |||
$('#dev-tools-svg-normalize-titles').val(normalizeSvgFileTitle(title)); | |||
setStatus('현재 SVG 파일명을 입력했습니다.', 'ok'); | |||
} | |||
function fillRecentFlagSvgTitles() { | |||
setButtonsDisabled(true); | |||
setStatus('최근 Flag SVG 목록을 가져오는 중...'); | |||
withApi(function (api) { | |||
api.get({ | |||
action: 'query', | |||
list: 'allimages', | |||
aiprefix: 'Flag-', | |||
aifrom: 'Flag-', | |||
aiprop: 'timestamp|mime|url', | |||
ailimit: 50, | |||
format: 'json', | |||
formatversion: 2, | |||
_: Date.now() | |||
}).then(function (data) { | |||
var images = data && data.query ? data.query.allimages || [] : []; | |||
var titles = images.filter(function (image) { | |||
var name = image && image.name ? String(image.name) : ''; | |||
return /\.svg$/i.test(name); | |||
}).map(function (image) { | |||
return 'File:' + image.name; | |||
}); | |||
$('#dev-tools-svg-normalize-titles').val(titles.join('\n')); | |||
setStatus('최근 Flag SVG ' + titles.length + '개를 입력했습니다.', titles.length ? 'ok' : 'error'); | |||
}).catch(function (err) { | |||
setStatus('최근 Flag SVG 목록 로드 실패: ' + (err && err.message ? err.message : err), 'error'); | |||
}).finally(function () { | |||
setButtonsDisabled(false); | |||
}); | |||
}, function () { | |||
setButtonsDisabled(false); | |||
setStatus('mediawiki.api 로드 실패', 'error'); | |||
}); | |||
} | |||
function parseSvgNumber(value) { | |||
var match = String(value == null ? '' : value).trim().match(/^-?\d+(?:\.\d+)?/); | |||
return match ? parseFloat(match[0]) : NaN; | |||
} | |||
function parseSvgViewBox(root) { | |||
var viewBox = root.getAttribute('viewBox') || root.getAttribute('viewbox') || ''; | |||
var parts = viewBox.trim().split(/[\s,]+/).map(function (part) { return parseFloat(part); }); | |||
var width; | |||
var height; | |||
if (parts.length === 4 && parts.every(function (value) { return Number.isFinite(value); }) && parts[2] > 0 && parts[3] > 0) { | |||
return { x: parts[0], y: parts[1], width: parts[2], height: parts[3] }; | |||
} | |||
width = parseSvgNumber(root.getAttribute('width')); | |||
height = parseSvgNumber(root.getAttribute('height')); | |||
if (Number.isFinite(width) && Number.isFinite(height) && width > 0 && height > 0) { | |||
return { x: 0, y: 0, width: width, height: height }; | |||
} | |||
return null; | |||
} | |||
function formatSvgNumber(value) { | |||
var rounded = Math.round(value * 1000) / 1000; | |||
if (Math.abs(rounded) < 0.0005) rounded = 0; | |||
return String(rounded).replace(/\.0+$/, '').replace(/(\.\d*?)0+$/, '$1'); | |||
} | |||
function serializeSvgDocument(doc, hadXmlDecl) { | |||
var text = new XMLSerializer().serializeToString(doc); | |||
if (hadXmlDecl && text.indexOf('<?xml') !== 0) { | |||
text = '<?xml version="1.0" encoding="UTF-8"?>\n' + text; | |||
} | |||
return text + (text.slice(-1) === '\n' ? '' : '\n'); | |||
} | |||
function renderSvgTextToCanvas(svgText, viewport) { | |||
return new Promise(function (resolve, reject) { | |||
var blob = new Blob([svgText], { type: 'image/svg+xml;charset=utf-8' }); | |||
var url = URL.createObjectURL(blob); | |||
var img = new Image(); | |||
var maxSide = 1600; | |||
var scale = Math.min(maxSide / Math.max(viewport.width, viewport.height), 4); | |||
var width = Math.max(1, Math.round(viewport.width * scale)); | |||
var height = Math.max(1, Math.round(viewport.height * scale)); | |||
var canvas = document.createElement('canvas'); | |||
var ctx; | |||
canvas.width = width; | |||
canvas.height = height; | |||
ctx = canvas.getContext('2d'); | |||
img.onload = function () { | |||
try { | |||
ctx.clearRect(0, 0, width, height); | |||
ctx.drawImage(img, 0, 0, width, height); | |||
URL.revokeObjectURL(url); | |||
resolve({ canvas: canvas, width: width, height: height }); | |||
} catch (err) { | |||
URL.revokeObjectURL(url); | |||
reject(err); | |||
} | |||
}; | |||
img.onerror = function () { | |||
URL.revokeObjectURL(url); | |||
reject(new Error('SVG 렌더링 실패')); | |||
}; | |||
img.src = url; | |||
}); | |||
} | |||
function findCanvasAlphaBounds(canvas) { | |||
var ctx = canvas.getContext('2d'); | |||
var width = canvas.width; | |||
var height = canvas.height; | |||
var data = ctx.getImageData(0, 0, width, height).data; | |||
var minX = width; | |||
var minY = height; | |||
var maxX = -1; | |||
var maxY = -1; | |||
var x; | |||
var y; | |||
var alpha; | |||
var index; | |||
for (y = 0; y < height; y += 1) { | |||
for (x = 0; x < width; x += 1) { | |||
index = (y * width + x) * 4 + 3; | |||
alpha = data[index]; | |||
if (alpha > 3) { | |||
if (x < minX) minX = x; | |||
if (y < minY) minY = y; | |||
if (x > maxX) maxX = x; | |||
if (y > maxY) maxY = y; | |||
} | |||
} | |||
} | |||
if (maxX < minX || maxY < minY) return null; | |||
return { minX: minX, minY: minY, maxX: maxX, maxY: maxY }; | |||
} | |||
function cropSvgTextToRenderedPixels(svgText) { | |||
var parser = new DOMParser(); | |||
var doc = parser.parseFromString(svgText, 'image/svg+xml'); | |||
var root = doc.documentElement; | |||
var parserError = doc.querySelector('parsererror'); | |||
var viewport; | |||
var hadXmlDecl = /^\s*<\?xml/i.test(svgText); | |||
if (parserError || !root || String(root.nodeName || '').toLowerCase() !== 'svg') { | |||
return Promise.reject(new Error('SVG 파싱 실패')); | |||
} | |||
viewport = parseSvgViewBox(root); | |||
if (!viewport) return Promise.reject(new Error('viewBox/width/height를 읽을 수 없음')); | |||
return renderSvgTextToCanvas(svgText, viewport).then(function (rendered) { | |||
var bounds = findCanvasAlphaBounds(rendered.canvas); | |||
var padX; | |||
var padY; | |||
var cropX; | |||
var cropY; | |||
var cropW; | |||
var cropH; | |||
var oldViewBox; | |||
var newViewBox; | |||
var style; | |||
if (!bounds) throw new Error('보이는 픽셀이 없음'); | |||
padX = Math.max(1, Math.round(rendered.width * 0.002)); | |||
padY = Math.max(1, Math.round(rendered.height * 0.002)); | |||
bounds.minX = Math.max(0, bounds.minX - padX); | |||
bounds.minY = Math.max(0, bounds.minY - padY); | |||
bounds.maxX = Math.min(rendered.width - 1, bounds.maxX + padX); | |||
bounds.maxY = Math.min(rendered.height - 1, bounds.maxY + padY); | |||
cropX = viewport.x + (bounds.minX / rendered.width) * viewport.width; | |||
cropY = viewport.y + (bounds.minY / rendered.height) * viewport.height; | |||
cropW = ((bounds.maxX - bounds.minX + 1) / rendered.width) * viewport.width; | |||
cropH = ((bounds.maxY - bounds.minY + 1) / rendered.height) * viewport.height; | |||
if (!(cropW > 0 && cropH > 0)) throw new Error('계산된 크롭 범위가 잘못됨'); | |||
oldViewBox = [viewport.x, viewport.y, viewport.width, viewport.height].map(formatSvgNumber).join(' '); | |||
newViewBox = [cropX, cropY, cropW, cropH].map(formatSvgNumber).join(' '); | |||
root.setAttribute('viewBox', newViewBox); | |||
root.setAttribute('width', formatSvgNumber(cropW)); | |||
root.setAttribute('height', formatSvgNumber(cropH)); | |||
root.setAttribute('overflow', 'hidden'); | |||
root.setAttribute('preserveAspectRatio', 'xMidYMid meet'); | |||
style = root.getAttribute('style') || ''; | |||
if (!/overflow\s*:/i.test(style)) { | |||
root.setAttribute('style', (style ? style.replace(/;?\s*$/, '; ') : '') + 'overflow:hidden;'); | |||
} | |||
return { | |||
text: serializeSvgDocument(doc, hadXmlDecl), | |||
oldViewBox: oldViewBox, | |||
newViewBox: newViewBox, | |||
cropWidth: cropW, | |||
cropHeight: cropH, | |||
changed: oldViewBox !== newViewBox | |||
}; | |||
}); | |||
} | |||
function fetchSvgFileText(api, title) { | |||
title = normalizeSvgFileTitle(title); | |||
return api.get({ | |||
action: 'query', | |||
prop: 'imageinfo', | |||
titles: normalizeApiTitle(title), | |||
iiprop: 'url|mime', | |||
format: 'json', | |||
formatversion: 2, | |||
_: Date.now() | |||
}).then(function (data) { | |||
var pages = data && data.query ? data.query.pages || [] : []; | |||
var page = pages[0]; | |||
var info = page && page.imageinfo ? page.imageinfo[0] : null; | |||
var url = info && info.url ? info.url : ''; | |||
if (!page || page.missing) throw new Error('파일 문서 없음'); | |||
if (!url) throw new Error('파일 URL 없음'); | |||
if (!/\.svg(?:\?|$)/i.test(url) && info.mime !== 'image/svg+xml') throw new Error('SVG 파일이 아님'); | |||
return fetch(url + (url.indexOf('?') === -1 ? '?' : '&') + '_=' + Date.now(), { | |||
credentials: 'same-origin', | |||
cache: 'no-store' | |||
}).then(function (response) { | |||
if (!response.ok) throw new Error('SVG 다운로드 실패: HTTP ' + response.status); | |||
return response.text(); | |||
}); | |||
}); | |||
} | |||
function uploadNormalizedSvg(api, title, svgText, comment) { | |||
var filename = svgFileUploadName(title); | |||
var blob = new Blob([svgText], { type: 'image/svg+xml;charset=utf-8' }); | |||
return api.getToken('csrf').then(function (token) { | |||
var form = new FormData(); | |||
form.append('action', 'upload'); | |||
form.append('filename', filename); | |||
form.append('file', blob, filename); | |||
form.append('ignorewarnings', '1'); | |||
form.append('comment', comment || 'Normalize SVG viewBox for CLBI flag thumbnail'); | |||
form.append('token', token); | |||
form.append('format', 'json'); | |||
form.append('formatversion', '2'); | |||
return fetch(mw.util.wikiScript('api'), { | |||
method: 'POST', | |||
credentials: 'same-origin', | |||
body: form | |||
}).then(function (response) { | |||
if (!response.ok) throw new Error('업로드 실패: HTTP ' + response.status); | |||
return response.json(); | |||
}).then(function (data) { | |||
if (data && data.error) throw new Error(data.error.info || data.error.code || '업로드 API 오류'); | |||
if (!data || !data.upload || (data.upload.result !== 'Success' && data.upload.result !== 'Warning')) { | |||
throw new Error('업로드 결과 확인 실패'); | |||
} | |||
return data; | |||
}); | |||
}); | |||
} | |||
function normalizeOneSvgFile(api, title) { | |||
return fetchSvgFileText(api, title).then(function (text) { | |||
return cropSvgTextToRenderedPixels(text).then(function (normalized) { | |||
return uploadNormalizedSvg(api, title, normalized.text, 'CLBI SVG flag viewBox normalization').then(function () { | |||
return { | |||
title: normalizeSvgFileTitle(title), | |||
ok: true, | |||
message: normalized.oldViewBox + ' → ' + normalized.newViewBox | |||
}; | |||
}); | |||
}); | |||
}); | |||
} | |||
function runSvgNormalizeUpload() { | |||
var titles = parseSvgTitleList($('#dev-tools-svg-normalize-titles').val()); | |||
var results = []; | |||
if (!titles.length) { | |||
setStatus('정규화할 SVG 파일명을 입력하세요.', 'error'); | |||
return; | |||
} | |||
if (!window.confirm('SVG 파일 ' + titles.length + '개를 실제 보이는 픽셀 범위 기준으로 정규화한 뒤 같은 파일명으로 재업로드합니다. 계속할까요?')) { | |||
return; | |||
} | |||
setButtonsDisabled(true); | |||
setStatus('SVG 정규화 준비 중...'); | |||
renderSvgNormalizeLog([]); | |||
withApi(function (api) { | |||
mapLimit(titles, 1, function (title, index) { | |||
setStatus('SVG 정규화 중... ' + (index + 1) + '/' + titles.length + ' ' + title); | setStatus('SVG 정규화 중... ' + (index + 1) + '/' + titles.length + ' ' + title); | ||
return normalizeOneSvgFile(api, title).then(function (result) { | return normalizeOneSvgFile(api, title).then(function (result) { | ||
results.push(result); | results.push(result); | ||
renderSvgNormalizeLog(results); | renderSvgNormalizeLog(results); | ||
}).catch(function (err) { | }).catch(function (err) { | ||
results.push({ | results.push({ | ||
title: normalizeSvgFileTitle(title), | title: normalizeSvgFileTitle(title), | ||
ok: false, | ok: false, | ||
message: err && err.message ? err.message : String(err) | message: err && err.message ? err.message : String(err) | ||
}); | }); | ||
renderSvgNormalizeLog(results); | renderSvgNormalizeLog(results); | ||
}); | }); | ||
}).then(function () { | }).then(function () { | ||
var ok = results.filter(function (item) { return item.ok; }).length; | var ok = results.filter(function (item) { return item.ok; }).length; | ||
var failed = results.length - ok; | var failed = results.length - ok; | ||
setStatus('SVG 정규화 완료: 성공 ' + ok + '개 / 실패 ' + failed + '개', failed ? 'error' : 'ok'); | setStatus('SVG 정규화 완료: 성공 ' + ok + '개 / 실패 ' + failed + '개', failed ? 'error' : 'ok'); | ||
}).finally(function () { | }).finally(function () { | ||
setButtonsDisabled(false); | setButtonsDisabled(false); | ||
}); | }); | ||
}, function () { | }, function () { | ||
setButtonsDisabled(false); | setButtonsDisabled(false); | ||
setStatus('mediawiki.api 로드 실패', 'error'); | setStatus('mediawiki.api 로드 실패', 'error'); | ||
}); | }); | ||
} | } | ||
function bindEvents() { | function bindEvents() { | ||
$(document).on('click', '#dev-tools-panel .dev-tools-button-disabled', function (event) { | $(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-section-collapsible .dev-tools-section-toggle', function () { | |||
var $section = $(this).closest('.dev-tools-section-collapsible'); | |||
var sectionKey = String($section.data('devtools-section') || '').trim(); | |||
var willCollapse = !$section.hasClass('is-collapsed'); | |||
$section.toggleClass('is-collapsed', willCollapse); | |||
$(this).attr('aria-expanded', willCollapse ? 'false' : 'true'); | |||
$(this).find('.dev-tools-section-caret').text(willCollapse ? '+' : '−'); | |||
if (sectionKey) { | |||
localStorage.setItem('dev-tools-section-' + sectionKey + '-collapsed', willCollapse ? 'true' : 'false'); | |||
} | |||
}); | |||
$(document).on('click', '#dev-tools-export', exportSources); | |||
$(document).on('change', '#dev-tools-package-file', analyzeSelectedPackage); | |||
$(document).on('click', '#dev-tools-package-analyze', analyzeSelectedPackage); | |||
$(document).on('click', '#dev-tools-package-install', installPackagePlan); | |||
$(document).on('click', '#dev-tools-svg-current', fillCurrentSvgFileTitle); | |||
$(document).on('click', '#dev-tools-svg-recent', fillRecentFlagSvgTitles); | |||
$(document).on('click', '#dev-tools-svg-normalize', runSvgNormalizeUpload); | |||
$(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-decoration-edit-toggle', toggleDecorationEditMode); | |||
$(document).on('click', '#dev-tools-decoration-save', saveDecorationEditor); | |||
$(document).on('click', '#dev-tools-decoration-cancel', cancelDecorationLayerEditor); | |||
$(document).on('click', '#dev-tools-decoration-add', addDecorationLayer); | |||
$(document).on('click', '#dev-tools-decoration-delete', deleteDecorationLayer); | |||
$(document).on('click', '#dev-tools-decoration-grid-toggle', function () { | |||
decoSetGridEnabled(!decoIsGridEnabled()); | |||
}); | |||
$(document).on('click', '#dev-tools-decoration-target-continent', targetActiveDecorationContinent); | |||
$(document).on('click', '#dev-tools-decoration-center', centerDecorationLayer); | |||
$(document).on('click', '#dev-tools-decoration-fit-width', fitDecorationLayerWidth); | |||
$(document).on('click', '.dev-tools-decoration-layer', function () { | |||
selectDecorationLayer($(this).attr('data-decoration-id') || ''); | |||
}); | |||
$(document).on('click', '.dev-tools-decoration-work-preview', function () { | |||
selectDecorationLayer(this.getAttribute('data-decoration-id') || ''); | |||
}); | |||
$(document).on('pointermove.devToolsDecorationLayers', updateDecorationDragMove); | |||
$(document).on('pointerup.devToolsDecorationLayers pointercancel.devToolsDecorationLayers', stopDecorationDrag); | |||
$(document).on('keydown.devToolsDecorationLayers', function (event) { | |||
var tag = event.target && event.target.tagName ? event.target.tagName.toLowerCase() : ''; | |||
var entry; | |||
var step; | |||
var x; | |||
var y; | |||
if (!decorationEditor.editMode) return; | |||
if (tag === 'input' || tag === 'textarea' || tag === 'select') return; | |||
if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight' && event.key !== 'ArrowUp' && event.key !== 'ArrowDown') return; | |||
entry = getDecorationSelectedLayer(); | |||
if (!entry) return; | |||
event.preventDefault(); | event.preventDefault(); | ||
event. | step = event.shiftKey ? 10 : 1; | ||
x = Number(entry.x) || 0; | |||
y = Number(entry.y) || 0; | |||
if (event.key === 'ArrowLeft') x -= step; | |||
if (event.key === 'ArrowRight') x += step; | |||
if (event.key === 'ArrowUp') y -= step; | |||
if (event.key === 'ArrowDown') y += step; | |||
setDecorationLayerPosition(x, y); | |||
}); | }); | ||
$(document).on('click', '.clbi-nations-tabpanel-tab[data-continent]', function () { | |||
$(document).on('click', '. | window.setTimeout(refreshDecorationContinentReadout, 0); | ||
}); | }); | ||
$(document).on('input change', '#dev-tools-decoration-label, #dev-tools-decoration-src, #dev-tools-decoration-target, #dev-tools-decoration-era, #dev-tools-decoration-continent, #dev-tools-decoration-x, #dev-tools-decoration-y, #dev-tools-decoration-width, #dev-tools-decoration-height, #dev-tools-decoration-opacity, #dev-tools-decoration-z', updateDecorationLayerFromForm); | |||
$(document).on('click', '#dev-tools-xp-add', function () { | $(document).on('click', '#dev-tools-xp-add', function () { | ||
2026년 7월 7일 (화) 01:06 판
(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 DECORATIONS_TITLE = 'MediaWiki:Decorations.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 decorationEditor = {
active: false,
preview: null,
target: null,
drag: null,
origin: null
};
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:Nation_List_Manager.js',
'MediaWiki:Nation_List_Manager.css',
'MediaWiki:LanguageStatus.json',
'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, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
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('국가 목록', 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 '';
var svgCollapsed = localStorage.getItem('dev-tools-section-svg-collapsed');
var svgIsCollapsed = svgCollapsed === null ? true : svgCollapsed === 'true';
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>' +
'<div class="dev-tools-section dev-tools-package-section">' +
'<div class="dev-tools-section-title">PACKAGE INSTALLER</div>' +
'<input id="dev-tools-package-file" class="dev-tools-file-input" type="file" accept=".zip,application/zip,application/x-zip-compressed">' +
'<div class="dev-tools-grid">' +
'<button type="button" class="dev-tools-button" id="dev-tools-package-analyze">분석</button>' +
'<button type="button" class="dev-tools-button dev-tools-danger" id="dev-tools-package-install" disabled>적용</button>' +
'</div>' +
'<div class="dev-tools-package-summary" id="dev-tools-package-summary">ZIP을 선택하면 적용 대상이 표시됩니다.</div>' +
'<div class="dev-tools-package-list" id="dev-tools-package-list"></div>' +
'<div class="dev-tools-note">manifest.json이 있으면 그 매핑을 우선하고, 없으면 js/ css/ mediawiki/ page/ manage/ 경로를 자동 추론합니다. PHP·server 파일·이미지 업로드는 적용하지 않습니다. 적용 전 현재 문서 백업 ZIP을 자동으로 내려받습니다.</div>' +
'</div>' +
'<div class="dev-tools-section dev-tools-svg-section dev-tools-section-collapsible' + (svgIsCollapsed ? ' is-collapsed' : '') + '" data-devtools-section="svg">' +
'<button type="button" class="dev-tools-section-toggle" aria-expanded="' + (svgIsCollapsed ? 'false' : 'true') + '">' +
'<span class="dev-tools-section-caret">' + (svgIsCollapsed ? '+' : '−') + '</span>' +
'<span class="dev-tools-section-toggle-label">SVG FLAG NORMALIZER</span>' +
'</button>' +
'<div class="dev-tools-section-content">' +
'<textarea id="dev-tools-svg-normalize-titles" class="dev-tools-textarea dev-tools-svg-textarea" rows="4" placeholder="파일:Flag-Example.svg\nFile:Flag-Example-2.svg"></textarea>' +
'<div class="dev-tools-grid">' +
'<button type="button" class="dev-tools-button" id="dev-tools-svg-current">현재 파일</button>' +
'<button type="button" class="dev-tools-button" id="dev-tools-svg-recent">최근 Flag SVG</button>' +
'<button type="button" class="dev-tools-button dev-tools-danger" id="dev-tools-svg-normalize">정규화 업로드</button>' +
'</div>' +
'<div class="dev-tools-svg-summary" id="dev-tools-svg-summary">SVG 파일명의 목록을 넣고 정규화하면 투명 여백/아트보드 밖 요소 때문에 작아지는 썸네일을 파일 자체에서 정리합니다.</div>' +
'<div class="dev-tools-package-list dev-tools-svg-list" id="dev-tools-svg-list"></div>' +
'<div class="dev-tools-note">MediaWiki 썸네일은 SVG 원본의 viewBox/viewport를 기준으로 래스터화합니다. CSS로 고치는 문제가 아니므로, 이 도구는 SVG를 다시 읽어 실제 보이는 픽셀 범위로 viewBox를 정리한 뒤 같은 파일명으로 재업로드합니다.</div>' +
'</div>' +
'</div>';
}
function buildProgressToolsHtml() {
if (!isOwner()) return '';
var collapsed = localStorage.getItem('dev-tools-section-progress-collapsed');
var isCollapsed = collapsed === null ? true : collapsed === 'true';
return '' +
'<div class="dev-tools-section dev-tools-progress-section dev-tools-section-collapsible' + (isCollapsed ? ' is-collapsed' : '') + '" data-devtools-section="progress">' +
'<button type="button" class="dev-tools-section-toggle" aria-expanded="' + (isCollapsed ? 'false' : 'true') + '">' +
'<span class="dev-tools-section-caret">' + (isCollapsed ? '+' : '−') + '</span>' +
'<span class="dev-tools-section-toggle-label">PROGRESS TEST</span>' +
'</button>' +
'<div class="dev-tools-section-content">' +
'<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>' +
'</div>';
}
function buildLanguageStatusToolsHtml() {
if (!isOwner()) return '';
var collapsed = localStorage.getItem('dev-tools-section-language-collapsed');
var isCollapsed = collapsed === null ? true : collapsed === 'true';
return '' +
'<div class="dev-tools-section dev-tools-language-section dev-tools-section-collapsible' + (isCollapsed ? ' is-collapsed' : '') + '" data-devtools-section="language">' +
'<button type="button" class="dev-tools-section-toggle" aria-expanded="' + (isCollapsed ? 'false' : 'true') + '">' +
'<span class="dev-tools-section-caret">' + (isCollapsed ? '+' : '−') + '</span>' +
'<span class="dev-tools-section-toggle-label">LANGUAGE STATUS</span>' +
'</button>' +
'<div class="dev-tools-section-content">' +
'<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>' +
'</div>';
}
function buildDecorationToolsHtml() {
if (!hasDevToolsAccess()) return '';
var collapsed = localStorage.getItem('dev-tools-section-decoration-collapsed');
var isCollapsed = collapsed === null ? false : collapsed === 'true';
var gridOn = localStorage.getItem('dev-tools-decoration-grid-enabled') !== 'false';
return '' +
'<div class="dev-tools-section dev-tools-decoration-section dev-tools-section-collapsible' + (isCollapsed ? ' is-collapsed' : '') + '" data-devtools-section="decoration">' +
'<button type="button" class="dev-tools-section-toggle" aria-expanded="' + (isCollapsed ? 'false' : 'true') + '">' +
'<span class="dev-tools-section-caret">' + (isCollapsed ? '+' : '−') + '</span>' +
'<span class="dev-tools-section-toggle-label">DECORATION EDITOR</span>' +
'</button>' +
'<div class="dev-tools-section-content">' +
'<div class="dev-tools-decoration-headline">' +
'<div class="dev-tools-decoration-mode" id="dev-tools-decoration-mode">VIEW MODE</div>' +
'<div class="dev-tools-decoration-page" id="dev-tools-decoration-page">PAGE: SYNC</div>' +
'</div>' +
'<div class="dev-tools-decoration-mainbar">' +
'<button type="button" class="dev-tools-button" id="dev-tools-decoration-edit-toggle">EDIT MODE</button>' +
'<button type="button" class="dev-tools-button" id="dev-tools-decoration-save" disabled>SAVE</button>' +
'<button type="button" class="dev-tools-button" id="dev-tools-decoration-cancel" disabled>CANCEL</button>' +
'</div>' +
'<div class="dev-tools-decoration-layerbar">' +
'<button type="button" class="dev-tools-button" id="dev-tools-decoration-add" disabled>+ LAYER</button>' +
'<button type="button" class="dev-tools-button dev-tools-danger" id="dev-tools-decoration-delete" disabled>DELETE</button>' +
'<button type="button" class="dev-tools-button" id="dev-tools-decoration-grid-toggle" data-grid="' + (gridOn ? '1' : '0') + '">' + (gridOn ? 'GRID ON' : 'GRID OFF') + '</button>' +
'</div>' +
'<div class="dev-tools-decoration-layer-list" id="dev-tools-decoration-layer-list">SYNC</div>' +
'<label class="dev-tools-label" for="dev-tools-decoration-label">LAYER NAME</label>' +
'<input id="dev-tools-decoration-label" class="dev-tools-input" type="text" value="" placeholder="layer name">' +
'<label class="dev-tools-label" for="dev-tools-decoration-src">IMAGE FILE / URL</label>' +
'<input id="dev-tools-decoration-src" class="dev-tools-input" type="text" placeholder="Example_Deco_1372.webp">' +
'<label class="dev-tools-label" for="dev-tools-decoration-target">TARGET SELECTOR</label>' +
'<input id="dev-tools-decoration-target" class="dev-tools-input" type="text" value=".liberty-content-main">' +
'<div class="dev-tools-decoration-continent-row">' +
'<div class="dev-tools-decoration-continent-readout" id="dev-tools-decoration-continent-readout">ERA: NONE / CONTINENT: NONE</div>' +
'<button type="button" class="dev-tools-button" id="dev-tools-decoration-target-continent" disabled>TARGET</button>' +
'</div>' +
'<div class="dev-tools-decoration-scope-grid">' +
'<label><span>ERA</span><input id="dev-tools-decoration-era" class="dev-tools-input" type="text" value="" placeholder="optional"></label>' +
'<label><span>CONTINENT</span><input id="dev-tools-decoration-continent" class="dev-tools-input" type="text" value="" placeholder="optional"></label>' +
'</div>' +
'<div class="dev-tools-decoration-grid">' +
'<label><span>X</span><input id="dev-tools-decoration-x" class="dev-tools-input" type="number" step="1" value="0"></label>' +
'<label><span>Y</span><input id="dev-tools-decoration-y" class="dev-tools-input" type="number" step="1" value="0"></label>' +
'<label><span>W</span><input id="dev-tools-decoration-width" class="dev-tools-input" type="number" step="1" value="1372"></label>' +
'<label><span>H</span><input id="dev-tools-decoration-height" class="dev-tools-input" type="text" value="auto"></label>' +
'<label><span>OP</span><input id="dev-tools-decoration-opacity" class="dev-tools-input" type="number" min="0" max="1" step="0.05" value="1"></label>' +
'<label><span>Z</span><input id="dev-tools-decoration-z" class="dev-tools-input" type="number" step="1" value="0"></label>' +
'</div>' +
'<div class="dev-tools-decoration-quickbar">' +
'<button type="button" class="dev-tools-button" id="dev-tools-decoration-center" disabled>CENTER</button>' +
'<button type="button" class="dev-tools-button" id="dev-tools-decoration-fit-width" disabled>FIT W</button>' +
'</div>' +
'<input id="dev-tools-decoration-id" type="hidden" value="">' +
'<div class="dev-tools-note">EDIT MODE에서만 조작합니다. 저장 전 변경은 CANCEL로 폐기됩니다. 레이어를 선택한 뒤 드래그/방향키로 이동하고, Shift+드래그는 수평·수직 축을 잠급니다. GRID ON은 중앙 가이드와 중앙 스냅을 켭니다.</div>' +
'</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"> 기존 이름 redirect 남김</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-delete-old" type="checkbox" checked> 기존 redirect 문서 제거</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">문서/파일의 실제 제목을 새 이름으로 바꿉니다. 기본값은 기존 제목 redirect를 남기지 않는 실제 이름 변경입니다. 이미 생긴 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() +
buildDecorationToolsHtml() +
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, #dev-tools-panel textarea').prop('disabled', !!disabled);
$('#dev-tools-toggle, #dev-tools-panel .dev-tools-section-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);
});
}
var PACKAGE_INSTALL_PLAN = null;
function readPackageFileAsArrayBuffer(file) {
return new Promise(function (resolve, reject) {
var reader = new FileReader();
reader.onload = function () { resolve(reader.result); };
reader.onerror = function () { reject(reader.error || new Error('ZIP 읽기 실패')); };
reader.readAsArrayBuffer(file);
});
}
function normalizePackagePath(path) {
return String(path || '')
.replace(/\\/g, '/')
.replace(/^\/+/, '')
.replace(/\/+/g, '/')
.trim();
}
function getPackageBasename(path) {
var clean = normalizePackagePath(path);
var parts = clean.split('/');
return parts[parts.length - 1] || clean;
}
function stripTextLikeExtension(name) {
return String(name || '').replace(/\.(txt|wiki|wikitext)$/i, '');
}
function titleFromScriptOrStylePath(path, prefix) {
var base = getPackageBasename(path);
return 'MediaWiki:' + base.replace(/\s+/g, '_');
}
function titleFromMediaWikiPath(path) {
var base = getPackageBasename(path);
return 'MediaWiki:' + base.replace(/\s+/g, '_');
}
function titleFromFilePath(path) {
var base = getPackageBasename(path).replace(/_/g, ' ').trim();
return base ? 'File:' + base : '';
}
function titleFromPagePath(path) {
var base = stripTextLikeExtension(getPackageBasename(path));
return base.replace(/_/g, ' ').trim();
}
function titleFromManagePath(path) {
var base = stripTextLikeExtension(getPackageBasename(path));
base = base.replace(/_/g, ' ').trim();
return base.indexOf('Manage:') === 0 || base.indexOf('관리:') === 0 ? base : 'Manage:' + base;
}
function titleFromModulePath(path) {
var base = stripTextLikeExtension(getPackageBasename(path));
base = base.replace(/_/g, ' ').trim();
return base.indexOf('Module:') === 0 || base.indexOf('모듈:') === 0 ? base : 'Module:' + base;
}
function titleFromTemplatePath(path) {
var base = stripTextLikeExtension(getPackageBasename(path));
base = base.replace(/_/g, ' ').trim();
return base.indexOf('Template:') === 0 || base.indexOf('틀:') === 0 ? base : 'Template:' + base;
}
function inferPackageTarget(path) {
var clean = normalizePackagePath(path);
var lower = clean.toLowerCase();
var ext = getTitleExtension(clean);
if (!clean || lower === 'manifest.json' || lower === 'readme.txt' || lower.indexOf('report/') === 0) {
return { skip: true, reason: '보고서/메타 파일' };
}
if (lower.indexOf('server/') === 0 || lower === 'localsettings.php' || ext === 'php') {
return { skip: true, reason: '서버/PHP 파일은 브라우저 인스톨러에서 제외' };
}
if (lower.indexOf('__macosx/') === 0 || /(^|\/)\.ds_store$/i.test(clean)) {
return { skip: true, reason: '시스템 메타 파일' };
}
if (lower.indexOf('js/') === 0 || (clean.indexOf('/') === -1 && ext === 'js')) {
return { target: titleFromScriptOrStylePath(clean), kind: 'js' };
}
if (lower.indexOf('css/') === 0 || (clean.indexOf('/') === -1 && ext === 'css')) {
return { target: titleFromScriptOrStylePath(clean), kind: 'css' };
}
if (lower.indexOf('mediawiki/') === 0) {
return { target: titleFromMediaWikiPath(clean), kind: ext || 'text' };
}
if (lower.indexOf('file/') === 0 || lower.indexOf('files/') === 0) {
return { target: titleFromFilePath(clean), kind: 'file' };
}
if (lower.indexOf('page/') === 0 || lower.indexOf('pages/') === 0) {
return { target: titleFromPagePath(clean), kind: 'wikitext' };
}
if (lower.indexOf('manage/') === 0) {
return { target: titleFromManagePath(clean), kind: 'wikitext' };
}
if (lower.indexOf('module/') === 0 || lower.indexOf('modules/') === 0) {
return { target: titleFromModulePath(clean), kind: 'module' };
}
if (lower.indexOf('template/') === 0 || lower.indexOf('templates/') === 0) {
return { target: titleFromTemplatePath(clean), kind: 'wikitext' };
}
if (SOURCE_TEXT_EXTENSIONS[ext]) {
return { skip: true, reason: '대상 문서를 추론할 수 없음' };
}
return { skip: true, reason: '비텍스트/미지원 파일' };
}
function getPackageContentModel(title, kind) {
var ext = getTitleExtension(title);
if (kind === 'js' || ext === 'js') return 'javascript';
if (kind === 'css' || ext === 'css') return 'css';
if (kind === 'json' || ext === 'json') return 'json';
if (kind === 'geojson' || ext === 'geojson') return 'json';
if (kind === 'module') return 'Scribunto';
return null;
}
function isPackageFileUploadItem(item) {
var target = String(item && item.target ? item.target : '').trim();
return !!item && (item.kind === 'file' || /^(file|파일):/i.test(target));
}
function packageUploadFilename(target) {
var title = String(target || '').replace(/_/g, ' ').trim();
return title.replace(/^(file|파일):\s*/i, '').trim();
}
function packageUploadMimeType(item) {
var ext = getTitleExtension((item && item.source) || (item && item.target) || '');
return SOURCE_TEXT_EXTENSIONS[ext] || 'application/octet-stream';
}
function readPackageItemBlob(item) {
return item.entry.async('blob').then(function (blob) {
var type = packageUploadMimeType(item);
if (blob && blob.type) return blob;
try {
return blob.slice(0, blob.size, type);
} catch (err) {
return blob;
}
});
}
function packageDelay(ms) {
return new Promise(function (resolve) {
window.setTimeout(resolve, Math.max(0, ms || 0));
});
}
function arrayBufferToHex(buffer) {
var bytes = new Uint8Array(buffer);
var out = [];
var i;
for (i = 0; i < bytes.length; i += 1) {
out.push(('0' + bytes[i].toString(16)).slice(-2));
}
return out.join('');
}
function sha1Blob(blob) {
if (!window.crypto || !window.crypto.subtle || !blob || typeof blob.arrayBuffer !== 'function') {
return Promise.resolve('');
}
return blob.arrayBuffer().then(function (buffer) {
return window.crypto.subtle.digest('SHA-1', buffer);
}).then(arrayBufferToHex).catch(function () {
return '';
});
}
function fetchPackageFileInfo(api, target) {
var filename = packageUploadFilename(target);
if (!filename) {
return Promise.resolve({
title: target || '',
missing: true,
sha1: '',
size: 0,
timestamp: '',
url: '',
mime: ''
});
}
return api.get({
action: 'query',
prop: 'imageinfo|info',
titles: 'File:' + filename,
iiprop: 'url|sha1|timestamp|size|mime',
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[0] ? page.imageinfo[0] : null;
return {
title: page && page.title ? page.title : ('File:' + filename),
missing: !page || !!page.missing || !info,
sha1: info && info.sha1 ? String(info.sha1) : '',
size: info && info.size ? Number(info.size) : 0,
timestamp: info && info.timestamp ? String(info.timestamp) : '',
url: info && info.url ? String(info.url) : '',
mime: info && info.mime ? String(info.mime) : ''
};
});
}
function fetchPackageFileInfoUntil(api, target, expectedSha1, previousSha1) {
var tries = 0;
function once() {
tries += 1;
return fetchPackageFileInfo(api, target).then(function (info) {
if (!expectedSha1 || info.sha1 === expectedSha1 || tries >= 4) return info;
if (previousSha1 && info.sha1 && info.sha1 !== previousSha1) return info;
return packageDelay(450 * tries).then(once);
});
}
return once();
}
function collectUploadWarnings(data) {
var out = [];
var uploadWarnings = data && data.upload && data.upload.warnings ? data.upload.warnings : null;
var warnings = data && data.warnings ? data.warnings : null;
function pushObject(prefix, obj) {
Object.keys(obj || {}).forEach(function (key) {
var value = obj[key];
var text = '';
if (value == null) {
text = key;
} else if (typeof value === 'string') {
text = key + ': ' + value;
} else if (value.warning) {
text = key + ': ' + value.warning;
} else if (value['*']) {
text = key + ': ' + value['*'];
} else {
try {
text = key + ': ' + JSON.stringify(value);
} catch (err) {
text = key;
}
}
out.push((prefix ? prefix + ' ' : '') + text);
});
}
pushObject('', uploadWarnings);
pushObject('api', warnings);
return out;
}
function compactSha(value) {
value = String(value || '');
return value ? value.slice(0, 8) : '-';
}
function uploadPackageFileItem(api, item, summary) {
var filename = packageUploadFilename(item.target);
if (!filename) {
return Promise.reject(new Error('파일 업로드 대상명을 확인할 수 없음: ' + item.target));
}
return readPackageItemBlob(item).then(function (blob) {
var localSha1 = '';
var beforeInfo = null;
return Promise.all([
sha1Blob(blob),
fetchPackageFileInfo(api, item.target).catch(function () { return null; })
]).then(function (pre) {
var form = new FormData();
localSha1 = pre[0] || '';
beforeInfo = pre[1] || null;
return api.getToken('csrf').then(function (token) {
form.append('action', 'upload');
form.append('filename', filename);
form.append('file', blob, filename);
form.append('ignorewarnings', '1');
form.append('comment', summary || ('Install package file: ' + item.source));
form.append('format', 'json');
form.append('formatversion', '2');
form.append('errorformat', 'plaintext');
form.append('token', token);
return $.ajax({
url: mw.util.wikiScript('api'),
method: 'POST',
data: form,
processData: false,
contentType: false
});
});
}).then(function (data) {
var result = data && data.upload ? data.upload.result : '';
var warnings = collectUploadWarnings(data);
if (data && data.error) {
throw new Error(data.error.info || data.error.code || '파일 업로드 실패');
}
if (result && result !== 'Success' && result !== 'Warning') {
throw new Error('파일 업로드 실패: ' + result);
}
return fetchPackageFileInfoUntil(api, item.target, localSha1, beforeInfo && beforeInfo.sha1 ? beforeInfo.sha1 : '').then(function (afterInfo) {
var verified = !!(localSha1 && afterInfo && afterInfo.sha1 === localSha1);
var beforeSha = beforeInfo && beforeInfo.sha1 ? beforeInfo.sha1 : '';
var afterSha = afterInfo && afterInfo.sha1 ? afterInfo.sha1 : '';
var changed = !!(afterSha && beforeSha && afterSha !== beforeSha);
var sameContent = !!(localSha1 && beforeSha && beforeSha === localSha1 && afterSha === localSha1);
if (localSha1 && afterSha && afterSha !== localSha1) {
throw new Error('파일 업로드 검증 실패: 업로드 후 SHA가 ZIP 내용과 다름 (' + compactSha(afterSha) + ' ≠ ' + compactSha(localSha1) + ')');
}
return {
data: data,
before: beforeInfo,
after: afterInfo,
localSha1: localSha1,
verified: verified,
changed: changed,
sameContent: sameContent,
warnings: warnings
};
});
});
});
}
function normalizePackageManifestFileEntry(entry) {
if (!entry || typeof entry !== 'object') return null;
var source = normalizePackagePath(entry.source || entry.path || entry.out || '');
var target = String(entry.target || entry.title || '').trim();
if (!source || !target) return null;
return {
source: source,
target: target,
kind: entry.type || entry.kind || (/^(file|파일):/i.test(target) ? 'file' : getTitleExtension(source) || 'text'),
fromManifest: true
};
}
function parsePackageManifest(zip) {
var file = zip.file('manifest.json');
if (!file) return Promise.resolve({ files: [] });
return file.async('string').then(function (text) {
var data;
try {
data = JSON.parse(text || '{}');
} catch (err) {
throw new Error('manifest.json 파싱 실패: ' + err.message);
}
var files = Array.isArray(data.files) ? data.files.map(normalizePackageManifestFileEntry).filter(Boolean) : [];
return {
name: data.package || data.name || data.mode || '',
files: files
};
});
}
function buildPackagePlanFromZip(zip, fileName) {
return parsePackageManifest(zip).then(function (manifest) {
var manifestMap = {};
var items = [];
var skipped = [];
var seenTargets = {};
(manifest.files || []).forEach(function (entry) {
manifestMap[normalizePackagePath(entry.source)] = entry;
});
Object.keys(zip.files).sort().forEach(function (rawPath) {
var zipEntry = zip.files[rawPath];
var path = normalizePackagePath(rawPath);
var mapped;
if (!path || zipEntry.dir) return;
if (manifestMap[path]) {
mapped = manifestMap[path];
} else {
mapped = inferPackageTarget(path);
}
if (!mapped || mapped.skip || !mapped.target) {
skipped.push({ source: path, reason: mapped && mapped.reason ? mapped.reason : '적용 제외' });
return;
}
if (seenTargets[mapped.target]) {
skipped.push({ source: path, reason: '대상 중복: ' + mapped.target });
return;
}
seenTargets[mapped.target] = true;
items.push({
source: path,
target: mapped.target,
kind: mapped.kind || getTitleExtension(path) || 'text',
entry: zipEntry,
fromManifest: !!mapped.fromManifest
});
});
return {
zip: zip,
fileName: fileName || 'package.zip',
packageName: manifest.name || String(fileName || 'package.zip').replace(/\.zip$/i, ''),
items: items,
skipped: skipped
};
});
}
function renderPackagePlan(plan) {
var html;
var skipped;
PACKAGE_INSTALL_PLAN = plan || null;
$('#dev-tools-package-install').prop('disabled', !(plan && plan.items && plan.items.length));
if (!plan) {
$('#dev-tools-package-summary').text('ZIP을 선택하면 적용 대상이 표시됩니다.');
$('#dev-tools-package-list').empty();
return;
}
skipped = plan.skipped || [];
$('#dev-tools-package-summary').text(
'PACKAGE: ' + plan.packageName + ' / 적용 ' + plan.items.length + '개 / 제외 ' + skipped.length + '개'
);
html = '<div class="dev-tools-package-subtitle">적용 대상</div>' +
(plan.items.length ? plan.items.map(function (item) {
var mode = isPackageFileUploadItem(item) ? ' <b>[파일 업로드 · 중복 강행 · SHA 검증]</b>' : '';
return '<div class="dev-tools-package-row">' +
'<span class="dev-tools-package-source">' + escapeHtml(item.source) + '</span>' +
'<span class="dev-tools-package-arrow">→</span>' +
'<span class="dev-tools-package-target">' + escapeHtml(item.target) + mode + '</span>' +
'</div>';
}).join('') : '<div class="dev-tools-package-empty">적용할 파일이 없습니다.</div>');
if (plan.installResults && plan.installResults.length) {
html += '<div class="dev-tools-package-subtitle">최근 적용 결과</div>' + plan.installResults.map(function (item) {
var ok = item.installed || item.skipped;
var status = item.error || item.detail || item.skipped || 'OK';
return '<div class="dev-tools-package-row' + (ok ? '' : ' dev-tools-package-skipped') + '">' +
'<span class="dev-tools-package-source">' + escapeHtml(item.source || item.target || '') + '</span>' +
'<span class="dev-tools-package-arrow">' + (ok ? '✓' : '×') + '</span>' +
'<span class="dev-tools-package-target">' + escapeHtml(status) + '</span>' +
'</div>';
}).join('');
}
if (skipped.length) {
html += '<div class="dev-tools-package-subtitle">제외</div>' + skipped.slice(0, 30).map(function (item) {
return '<div class="dev-tools-package-row dev-tools-package-skipped">' +
'<span class="dev-tools-package-source">' + escapeHtml(item.source) + '</span>' +
'<span class="dev-tools-package-arrow">×</span>' +
'<span class="dev-tools-package-target">' + escapeHtml(item.reason) + '</span>' +
'</div>';
}).join('');
if (skipped.length > 30) {
html += '<div class="dev-tools-package-empty">외 ' + (skipped.length - 30) + '개 제외</div>';
}
}
$('#dev-tools-package-list').html(html);
}
function analyzeSelectedPackage() {
var input = document.getElementById('dev-tools-package-file');
var file = input && input.files && input.files[0] ? input.files[0] : null;
if (!file) {
setStatus('ZIP 파일을 먼저 선택하세요.', 'error');
return;
}
setButtonsDisabled(true);
setStatus('패키지 분석 중...');
$('#dev-tools-package-install').prop('disabled', true);
loadScript(JSZIP_URL)
.then(function () { return readPackageFileAsArrayBuffer(file); })
.then(function (buffer) { return JSZip.loadAsync(buffer); })
.then(function (zip) { return buildPackagePlanFromZip(zip, file.name); })
.then(function (plan) {
renderPackagePlan(plan);
setStatus('패키지 분석 완료: 적용 ' + plan.items.length + '개 / 제외 ' + plan.skipped.length + '개', plan.items.length ? 'ok' : 'error');
})
.catch(function (err) {
PACKAGE_INSTALL_PLAN = null;
renderPackagePlan(null);
setStatus('패키지 분석 실패: ' + (err && err.message ? err.message : err), 'error');
})
.finally(function () {
setButtonsDisabled(false);
$('#dev-tools-package-install').prop('disabled', !(PACKAGE_INSTALL_PLAN && PACKAGE_INSTALL_PLAN.items.length));
});
}
function fetchPackageTargetPage(api, title) {
return api.get({
action: 'query',
prop: 'revisions|info',
titles: normalizeApiTitle(title),
rvprop: 'content|ids|timestamp',
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 {
title: title,
missing: true,
text: '',
ns: page ? page.ns : null,
contentmodel: page ? page.contentmodel : '',
revid: 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,
missing: false,
text: text,
ns: page.ns,
contentmodel: page.contentmodel || '',
revid: page.revisions && page.revisions[0] ? page.revisions[0].revid : null
};
});
}
function readPackageItemText(item) {
return item.entry.async('string').then(function (text) {
return String(text || '').replace(/^\uFEFF/, '');
});
}
function makePackageBackupZip(plan, pages) {
var zip = new JSZip();
var files = [];
var stamp = new Date().toISOString();
pages.forEach(function (page) {
var title = page.title || page.requestedTitle || '';
var path = 'backup/' + sanitizeTitlePath(title) + '.txt';
zip.file(path, page.missing ? '' : page.text);
files.push({
title: title,
path: path,
missing: !!page.missing,
revid: page.revid || null
});
});
zip.file('manifest.json', JSON.stringify({
generated: stamp,
mode: 'package-install-backup',
package: plan.packageName,
files: files
}, null, 2) + '\n');
zip.file('README.txt', 'CLBI package installer backup\nGenerated: ' + stamp + '\nPackage: ' + plan.packageName + '\n\n이 ZIP은 PACKAGE INSTALLER 적용 직전에 대상 문서들의 기존 내용을 저장한 백업입니다.\n');
return zip.generateAsync({ type: 'blob' });
}
function installPackagePlan() {
var plan = PACKAGE_INSTALL_PLAN;
if (!plan || !plan.items || !plan.items.length) {
setStatus('분석된 패키지가 없습니다.', 'error');
return;
}
if (!window.confirm('패키지 적용 전 백업 ZIP을 내려받은 뒤 ' + plan.items.length + '개 문서를 수정합니다. 계속할까요?')) {
return;
}
setButtonsDisabled(true);
setStatus('패키지 적용 준비 중...');
loadScript(JSZIP_URL)
.then(function () {
return new Promise(function (resolve, reject) {
withApi(resolve, function () { reject(new Error('mediawiki.api 로드 실패')); });
});
})
.then(function (api) {
var pagesByTarget = {};
var targets = plan.items.map(function (item) { return item.target; });
var installed = 0;
var skippedSame = 0;
var failed = 0;
var results = [];
setStatus('현재 문서 백업 수집 중...');
return mapLimit(targets, 4, function (title) {
return fetchPackageTargetPage(api, title).then(function (page) {
page.requestedTitle = title;
pagesByTarget[title] = page;
return page;
});
}).then(function (pages) {
setStatus('백업 ZIP 생성 중...');
return makePackageBackupZip(plan, pages).then(function (blob) {
var stamp = new Date().toISOString().replace(/[:.]/g, '-');
downloadBlob(blob, 'clbiwiki-package-backup-' + sanitizePathPart(plan.packageName) + '-' + stamp + '.zip');
});
}).then(function () {
setStatus('패키지 적용 중... 0/' + plan.items.length);
return plan.items.reduce(function (chain, item, index) {
return chain.then(function () {
var page = pagesByTarget[item.target] || { title: item.target, missing: true, text: '', revid: null };
var contentModel = getPackageContentModel(item.target, item.kind);
var summary = 'Install package: ' + plan.packageName + ' / ' + item.source;
if (isPackageFileUploadItem(item)) {
return uploadPackageFileItem(api, item, summary).then(function (uploadResult) {
var beforeSha = uploadResult.before && uploadResult.before.sha1 ? uploadResult.before.sha1 : '';
var afterSha = uploadResult.after && uploadResult.after.sha1 ? uploadResult.after.sha1 : '';
var detail = '파일 업로드 검증됨: ' + compactSha(beforeSha) + ' → ' + compactSha(afterSha);
if (uploadResult.sameContent) {
detail += ' / ZIP과 기존 파일 동일';
} else if (uploadResult.changed) {
detail += ' / 본체 교체됨';
}
if (uploadResult.warnings && uploadResult.warnings.length) {
detail += ' / 경고 강행: ' + uploadResult.warnings.slice(0, 2).join(' | ');
}
installed += 1;
results.push({ target: item.target, source: item.source, installed: true, uploaded: true, detail: detail });
setStatus('패키지 적용 중... ' + (index + 1) + '/' + plan.items.length + ' / 파일 검증 ' + compactSha(afterSha));
}).catch(function (err) {
failed += 1;
results.push({
target: item.target,
source: item.source,
error: err && err.message ? err.message : String(err)
});
setStatus('패키지 적용 중... ' + (index + 1) + '/' + plan.items.length + ' / 실패 ' + failed);
});
}
return readPackageItemText(item).then(function (nextText) {
var payload;
if (!page.missing && String(page.text || '') === String(nextText || '')) {
skippedSame += 1;
results.push({ target: item.target, source: item.source, skipped: 'same' });
setStatus('패키지 적용 중... ' + (index + 1) + '/' + plan.items.length);
return;
}
payload = {
action: 'edit',
title: item.target,
text: nextText,
summary: summary,
format: 'json',
formatversion: 2,
errorformat: 'plaintext'
};
if (!page.missing && page.revid) payload.baserevid = page.revid;
if (contentModel) payload.contentmodel = contentModel;
return api.postWithToken('csrf', payload).then(function () {
installed += 1;
results.push({ target: item.target, source: item.source, installed: true });
setStatus('패키지 적용 중... ' + (index + 1) + '/' + plan.items.length);
});
}).catch(function (err) {
failed += 1;
results.push({
target: item.target,
source: item.source,
error: err && err.message ? err.message : String(err)
});
setStatus('패키지 적용 중... ' + (index + 1) + '/' + plan.items.length + ' / 실패 ' + failed);
});
});
}, Promise.resolve());
}).then(function () {
var message = '패키지 적용 완료: 수정 ' + installed + ' / 동일 ' + skippedSame + ' / 실패 ' + failed;
plan.installResults = results.slice();
setStatus(message, failed ? 'error' : 'ok');
renderPackagePlan(plan);
});
})
.catch(function (err) {
setStatus('패키지 적용 실패: ' + (err && err.message ? err.message : err), 'error');
})
.finally(function () {
setButtonsDisabled(false);
$('#dev-tools-package-install').prop('disabled', !(PACKAGE_INSTALL_PLAN && PACKAGE_INSTALL_PLAN.items.length));
});
}
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 getDecorationPageKey() {
return getLanguageStatusPageKey();
}
function getDefaultDecorationTarget() {
if (document.querySelector('.clbi-nations-panel-stack')) return '.clbi-nations-panel-stack';
if (document.querySelector('.clbi-nations-board')) return '.clbi-nations-board';
if (document.querySelector('.liberty-content-main')) return '.liberty-content-main';
if (document.querySelector('.container-fluid.liberty-content')) return '.container-fluid.liberty-content';
return 'body';
}
function cssAttrEscape(value) {
return String(value || '').replace(/\\/g, '\\\\').replace(/"/g, '\\"');
}
function getActiveDecorationEra() {
var content = document.querySelector('.clbi-nations-era-content.is-active[data-era-content]:not([hidden])');
var title;
var globe;
if (content) return content.getAttribute('data-era-content') || '';
title = document.querySelector('.clbi-nations-era-title-plate.is-active[data-era]');
if (title) return title.getAttribute('data-era') || '';
globe = document.querySelector('.clbi-nations-globe-window[data-nations-globe]');
if (globe) {
return globe.getAttribute('data-current-era') || globe.getAttribute('data-nations-current-era') || globe.getAttribute('data-era-year') || '';
}
return '';
}
function getActiveDecorationEraPanel() {
var era = getActiveDecorationEra();
var selector;
if (!era) return document.querySelector('.clbi-nations-era-content.is-active[data-era-content]:not([hidden])');
selector = '.clbi-nations-era-content[data-era-content="' + cssAttrEscape(era) + '"]';
return document.querySelector(selector) || document.querySelector('.clbi-nations-era-content.is-active[data-era-content]:not([hidden])');
}
function getActiveDecorationContinent() {
var eraPanel = getActiveDecorationEraPanel();
var root = eraPanel || document;
var tab = root.querySelector('.clbi-nations-tabpanel-tab.is-active[data-continent], .clbi-nations-tabpanel-tab[aria-selected="true"][data-continent]');
var panel;
if (tab) return tab.getAttribute('data-continent') || '';
panel = root.querySelector('.clbi-nations-tabpanel-continent.is-active[data-continent-panel]');
if (panel) return panel.getAttribute('data-continent-panel') || '';
return '';
}
function getActiveDecorationScopeSelector() {
var era = getActiveDecorationEra();
var continent = getActiveDecorationContinent();
var selector = '.clbi-nations-panel-stack';
if (era) selector += ' .clbi-nations-era-content[data-era-content="' + cssAttrEscape(era) + '"]';
if (continent) selector += ' .clbi-nations-tabpanel-continent[data-continent-panel="' + cssAttrEscape(continent) + '"]';
return continent || era ? selector : '';
}
function getDefaultDecorationScopedTarget() {
return document.querySelector('.clbi-nations-panel-stack') ? '.clbi-nations-panel-stack' : getDefaultDecorationTarget();
}
function refreshDecorationScopeReadout() {
var era = getActiveDecorationEra();
var continent = getActiveDecorationContinent();
var readout = $('#dev-tools-decoration-continent-readout');
var scope = $('#dev-tools-decoration-scope-readout');
if (readout.length) {
readout.text('ERA: ' + (era || 'NONE') + ' / CONTINENT: ' + (continent || 'NONE'));
}
if (scope.length) {
scope.text('SCOPE: ' + (era || 'PAGE') + (continent ? (' / ' + continent) : ''));
}
}
function normalizeDecorationId(value) {
value = String(value || '').trim();
if (value) return value;
return 'decor-' + getDecorationPageKey().replace(/[^a-zA-Z0-9가-힣_-]+/g, '-').replace(/^-+|-+$/g, '') + '-' + Date.now();
}
function normalizeDecorationSrc(src) {
src = String(src || '').trim();
if (!src) return '';
if (/^(?:https?:)?\/\//i.test(src) || src.charAt(0) === '/' || src.indexOf('data:') === 0 || src.indexOf('blob:') === 0) return src;
return '/index.php/Special:Redirect/file/' + encodeURIComponent(src);
}
function readDecorationNumber(selector, fallback) {
var n = parseFloat($(selector).val());
return Number.isFinite(n) ? n : fallback;
}
function readDecorationHeight() {
var raw = String($('#dev-tools-decoration-height').val() || '').trim();
var n;
if (!raw || raw.toLowerCase() === 'auto') return null;
n = parseFloat(raw);
return Number.isFinite(n) && n > 0 ? Math.round(n) : null;
}
function readDecorationForm() {
var height = readDecorationHeight();
var entry = {
id: normalizeDecorationId($('#dev-tools-decoration-id').val()),
page: getDecorationPageKey(),
target: String($('#dev-tools-decoration-target').val() || '').trim() || getDefaultDecorationTarget(),
era: String($('#dev-tools-decoration-era').val() || '').trim(),
continent: String($('#dev-tools-decoration-continent').val() || '').trim(),
src: String($('#dev-tools-decoration-src').val() || '').trim(),
x: Math.round(readDecorationNumber('#dev-tools-decoration-x', 0)),
y: Math.round(readDecorationNumber('#dev-tools-decoration-y', 0)),
width: Math.max(1, Math.round(readDecorationNumber('#dev-tools-decoration-width', 1372))),
opacity: Math.max(0, Math.min(1, readDecorationNumber('#dev-tools-decoration-opacity', 1))),
zIndex: Math.round(readDecorationNumber('#dev-tools-decoration-z', 0)),
pointerEvents: 'none',
enabled: true
};
if (height) entry.height = height;
if (!entry.era) delete entry.era;
if (!entry.continent) delete entry.continent;
return entry;
}
function writeDecorationForm(entry) {
entry = entry || {};
$('#dev-tools-decoration-id').val(entry.id || normalizeDecorationId(''));
$('#dev-tools-decoration-target').val(entry.target || getDefaultDecorationTarget());
$('#dev-tools-decoration-era').val(entry.era || '');
$('#dev-tools-decoration-continent').val(entry.continent || '');
$('#dev-tools-decoration-src').val(entry.src || '');
refreshDecorationScopeReadout();
$('#dev-tools-decoration-x').val(entry.x == null ? 0 : Math.round(Number(entry.x) || 0));
$('#dev-tools-decoration-y').val(entry.y == null ? 0 : Math.round(Number(entry.y) || 0));
$('#dev-tools-decoration-width').val(entry.width == null ? 1372 : Math.max(1, Math.round(Number(entry.width) || 1372)));
$('#dev-tools-decoration-height').val(entry.height == null ? 'auto' : Math.max(1, Math.round(Number(entry.height) || 1)));
$('#dev-tools-decoration-opacity').val(entry.opacity == null ? 1 : Math.max(0, Math.min(1, Number(entry.opacity) || 0)));
$('#dev-tools-decoration-z').val(entry.zIndex == null ? 0 : Math.round(Number(entry.zIndex) || 0));
}
function cloneDecorationEntry(entry) {
return JSON.parse(JSON.stringify(entry || {}));
}
function setDecorationStartFromForm() {
decorationEditor.origin = cloneDecorationEntry(readDecorationForm());
setStatus('장식 START 위치 저장: X ' + decorationEditor.origin.x + ' / Y ' + decorationEditor.origin.y, 'ok');
}
function resetDecorationToStart() {
if (!decorationEditor.origin) {
decorationEditor.origin = cloneDecorationEntry(readDecorationForm());
}
writeDecorationForm(decorationEditor.origin);
updateDecorationPreview(true);
setStatus('장식 START 위치로 복귀', 'ok');
}
function getDecorationMoveStep(event) {
var step = Math.max(1, Math.round(readDecorationNumber('#dev-tools-decoration-step', 1)));
if (event && event.shiftKey) step *= 10;
return step;
}
function setDecorationPosition(x, y, silent) {
$('#dev-tools-decoration-x').val(Math.round(x));
$('#dev-tools-decoration-y').val(Math.round(y));
updateDecorationPreview(true);
if (!silent) setStatus('장식 위치: X ' + Math.round(x) + ' / Y ' + Math.round(y), 'ok');
}
function nudgeDecoration(dx, dy, event) {
var step = getDecorationMoveStep(event);
var x = readDecorationNumber('#dev-tools-decoration-x', 0) + (dx * step);
var y = readDecorationNumber('#dev-tools-decoration-y', 0) + (dy * step);
setDecorationPosition(x, y, false);
}
function zeroDecorationPosition() {
setDecorationPosition(0, 0, false);
}
function getDecorationTargetElement(entry) {
var selector = String(entry && entry.target || '').trim() || getDefaultDecorationTarget();
var base = document.querySelector(selector);
var era = String(entry && entry.era || '').trim();
var continent = String(entry && entry.continent || '').trim();
var eraSelector;
var continentSelector;
var scoped;
if (era) {
eraSelector = '.clbi-nations-era-content[data-era-content="' + cssAttrEscape(era) + '"]';
if (base) {
if (base.matches && base.matches(eraSelector)) scoped = base;
if (!scoped && base.querySelector) scoped = base.querySelector(eraSelector);
if (!scoped && base.closest) scoped = base.closest(eraSelector) || null;
}
base = scoped || document.querySelector(eraSelector) || base;
}
if (continent) {
continentSelector = '.clbi-nations-tabpanel-continent[data-continent-panel="' + cssAttrEscape(continent) + '"]';
scoped = null;
if (base) {
if (base.matches && base.matches(continentSelector)) scoped = base;
if (!scoped && base.querySelector) scoped = base.querySelector(continentSelector);
if (!scoped && base.closest) scoped = base.closest(continentSelector) || null;
}
base = scoped || document.querySelector(continentSelector) || base;
}
return base;
}
function centerDecorationX() {
var entry = readDecorationForm();
var target = getDecorationTargetElement(entry);
var width = entry.width || 0;
var x;
if (!target) {
setStatus('대상 컨테이너를 찾지 못했습니다: ' + entry.target, 'error');
return;
}
x = Math.round(((target.offsetWidth || target.getBoundingClientRect().width || 0) - width) / 2);
setDecorationPosition(x, entry.y || 0, false);
}
function fitDecorationWidth() {
var entry = readDecorationForm();
var target = getDecorationTargetElement(entry);
if (!target) {
setStatus('대상 컨테이너를 찾지 못했습니다: ' + entry.target, 'error');
return;
}
$('#dev-tools-decoration-width').val(Math.max(1, Math.round(target.offsetWidth || target.getBoundingClientRect().width || entry.width || 1)));
updateDecorationPreview(true);
setStatus('장식 너비를 target 폭에 맞춤', 'ok');
}
function writeDecorationDefaults() {
if (!$('#dev-tools-decoration-page').length) return;
$('#dev-tools-decoration-page').text('PAGE: ' + getDecorationPageKey());
if (!$('#dev-tools-decoration-id').val()) $('#dev-tools-decoration-id').val(normalizeDecorationId(''));
if (!$('#dev-tools-decoration-target').val()) $('#dev-tools-decoration-target').val(getDefaultDecorationTarget());
if (!$('#dev-tools-decoration-era').val()) $('#dev-tools-decoration-era').val('');
if (!$('#dev-tools-decoration-src').val()) $('#dev-tools-decoration-src').val(localStorage.getItem('dev-tools-decoration-last-src') || '');
refreshDecorationScopeReadout();
}
function getDecorationRegistryList(registry) {
if (!registry || typeof registry !== 'object') return [];
if (Array.isArray(registry)) return registry;
if (!Array.isArray(registry.decorations)) registry.decorations = [];
return registry.decorations;
}
function fetchDecorationRegistry(api) {
return api.get({
action: 'query',
prop: 'revisions',
titles: DECORATIONS_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 { version: 1, decorations: [] };
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 { version: 1, decorations: [] };
try {
return JSON.parse(text);
} catch (err) {
setStatus('Decorations JSON 파싱 실패', 'error');
return { version: 1, decorations: [] };
}
});
}
function ensureDecorationHost(target) {
var style;
if (!target) return;
target.setAttribute('data-wiki-decoration-host', '1');
style = window.getComputedStyle ? window.getComputedStyle(target) : null;
if (style && style.position === 'static') target.style.position = 'relative';
}
function removeDecorationPreview() {
if (decorationEditor.preview && decorationEditor.preview.parentNode) {
decorationEditor.preview.parentNode.removeChild(decorationEditor.preview);
}
decorationEditor.preview = null;
decorationEditor.target = null;
decorationEditor.drag = null;
decorationEditor.active = false;
$(document).off('.devToolsDecoration');
}
function getDecorationTargetScale(target) {
var rect;
if (!target) return { x: 1, y: 1 };
rect = target.getBoundingClientRect();
return {
x: rect.width && target.offsetWidth ? rect.width / target.offsetWidth : 1,
y: rect.height && target.offsetHeight ? rect.height / target.offsetHeight : 1
};
}
function bindDecorationPreviewDrag() {
var preview = decorationEditor.preview;
if (!preview || preview.getAttribute('data-decoration-drag-ready') === '1') return;
preview.setAttribute('data-decoration-drag-ready', '1');
preview.addEventListener('pointerdown', function (event) {
if (!decorationEditor.active) return;
event.preventDefault();
event.stopPropagation();
decorationEditor.drag = {
pointerId: event.pointerId,
startX: event.clientX,
startY: event.clientY,
x: readDecorationNumber('#dev-tools-decoration-x', 0),
y: readDecorationNumber('#dev-tools-decoration-y', 0)
};
if (preview.setPointerCapture) preview.setPointerCapture(event.pointerId);
preview.classList.add('is-dragging');
});
preview.addEventListener('wheel', function (event) {
var width;
var step;
if (!decorationEditor.active) return;
if (!event.ctrlKey && !event.altKey) return;
event.preventDefault();
step = event.shiftKey ? 20 : 5;
width = readDecorationNumber('#dev-tools-decoration-width', 1372);
width += event.deltaY < 0 ? step : -step;
$('#dev-tools-decoration-width').val(Math.max(1, Math.round(width)));
updateDecorationPreview(true);
}, { passive: false });
$(document)
.on('pointermove.devToolsDecoration', function (event) {
var drag = decorationEditor.drag;
var scale;
var x;
var y;
if (!drag) return;
scale = getDecorationTargetScale(decorationEditor.target);
x = Math.round(drag.x + (event.clientX - drag.startX) / (scale.x || 1));
y = Math.round(drag.y + (event.clientY - drag.startY) / (scale.y || 1));
$('#dev-tools-decoration-x').val(x);
$('#dev-tools-decoration-y').val(y);
updateDecorationPreview(true);
})
.on('pointerup.devToolsDecoration pointercancel.devToolsDecoration', function () {
if (decorationEditor.preview) decorationEditor.preview.classList.remove('is-dragging');
decorationEditor.drag = null;
})
.on('keydown.devToolsDecoration', function (event) {
var tag = event.target && event.target.tagName ? event.target.tagName.toLowerCase() : '';
var step;
var x;
var y;
if (!decorationEditor.active) return;
if (tag === 'input' || tag === 'textarea' || tag === 'select') return;
if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight' && event.key !== 'ArrowUp' && event.key !== 'ArrowDown') return;
event.preventDefault();
step = getDecorationMoveStep(event);
x = readDecorationNumber('#dev-tools-decoration-x', 0);
y = readDecorationNumber('#dev-tools-decoration-y', 0);
if (event.key === 'ArrowLeft') x -= step;
if (event.key === 'ArrowRight') x += step;
if (event.key === 'ArrowUp') y -= step;
if (event.key === 'ArrowDown') y += step;
$('#dev-tools-decoration-x').val(Math.round(x));
$('#dev-tools-decoration-y').val(Math.round(y));
updateDecorationPreview(true);
});
}
function updateDecorationPreview(silent) {
var entry = readDecorationForm();
var target = getDecorationTargetElement(entry);
var img = decorationEditor.preview;
if (!entry.src) {
if (!silent) setStatus('장식 이미지 파일명 또는 URL을 입력하세요.', 'error');
return false;
}
if (!target) {
if (!silent) setStatus('대상 컨테이너를 찾지 못했습니다: ' + entry.target, 'error');
return false;
}
if (!img || decorationEditor.target !== target) {
removeDecorationPreview();
img = document.createElement('img');
img.className = 'wiki-decoration dev-tools-decoration-preview';
img.setAttribute('aria-hidden', 'true');
img.setAttribute('alt', '');
img.setAttribute('tabindex', '0');
img.draggable = false;
target.appendChild(img);
decorationEditor.preview = img;
decorationEditor.target = target;
}
ensureDecorationHost(target);
img.src = normalizeDecorationSrc(entry.src);
img.style.left = entry.x + 'px';
img.style.top = entry.y + 'px';
img.style.width = entry.width + 'px';
img.style.height = entry.height ? entry.height + 'px' : 'auto';
img.style.opacity = String(entry.opacity);
img.style.zIndex = String(entry.zIndex);
img.style.pointerEvents = 'auto';
img.style.cursor = 'move';
img.style.touchAction = 'none';
img.setAttribute('data-decoration-id', entry.id);
if (entry.era) img.setAttribute('data-decoration-era', entry.era);
else img.removeAttribute('data-decoration-era');
if (entry.continent) img.setAttribute('data-decoration-continent', entry.continent);
else img.removeAttribute('data-decoration-continent');
localStorage.setItem('dev-tools-decoration-last-src', entry.src);
if (img.focus) {
try { img.focus({ preventScroll: true }); } catch (err) { img.focus(); }
}
decorationEditor.active = true;
bindDecorationPreviewDrag();
return true;
}
function copyDecorationJson() {
var entry = readDecorationForm();
var text = JSON.stringify(entry, null, 2);
var textarea;
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(function () {
setStatus('장식 JSON 복사 완료', 'ok');
}).catch(function () {
setStatus('클립보드 복사 실패', 'error');
});
return;
}
textarea = document.createElement('textarea');
textarea.value = text;
textarea.style.position = 'fixed';
textarea.style.left = '-9999px';
document.body.appendChild(textarea);
textarea.focus();
textarea.select();
document.execCommand('copy');
textarea.remove();
setStatus('장식 JSON 복사 완료', 'ok');
}
function targetActiveDecorationContinent() {
var era = getActiveDecorationEra();
var continent = getActiveDecorationContinent();
var selector = getActiveDecorationScopeSelector();
refreshDecorationScopeReadout();
if (!era && !continent) {
setStatus('활성 시대/대륙을 찾지 못했습니다.', 'error');
return;
}
$('#dev-tools-decoration-era').val(era || '');
$('#dev-tools-decoration-continent').val(continent || '');
$('#dev-tools-decoration-target').val(selector || getDefaultDecorationScopedTarget());
if (decorationEditor.active) updateDecorationPreview(true);
setStatus('장식 scope 지정: ' + (era || 'PAGE') + (continent ? (' / ' + continent) : ''), 'ok');
}
function saveDecorationEditor() {
var entry = readDecorationForm();
if (!entry.src) {
setStatus('장식 이미지 파일명 또는 URL을 입력하세요.', 'error');
return;
}
withApi(function (api) {
setButtonsDisabled(true);
setStatus('장식 JSON 저장 중...');
fetchDecorationRegistry(api).then(function (registry) {
var nextRegistry = registry && typeof registry === 'object' && !Array.isArray(registry) ? registry : { version: 1, decorations: [] };
var decorations = getDecorationRegistryList(nextRegistry);
var replaced = false;
decorations.forEach(function (item, index) {
if (item && item.id === entry.id) {
decorations[index] = entry;
replaced = true;
}
});
if (!replaced) decorations.push(entry);
nextRegistry.version = nextRegistry.version || 1;
nextRegistry.updatedAt = new Date().toISOString();
return api.postWithToken('csrf', {
action: 'edit',
title: DECORATIONS_TITLE,
text: JSON.stringify(nextRegistry, null, 2) + '\n',
summary: 'Update decoration: ' + entry.id,
contentmodel: 'json',
format: 'json',
formatversion: 2
});
}).then(function () {
setStatus('장식 JSON 저장 완료', 'ok');
if (window.CLBI_DECORATIONS && typeof window.CLBI_DECORATIONS.reload === 'function') {
window.CLBI_DECORATIONS.reload();
}
updateDecorationPreview(true);
}).catch(function (err) {
setStatus('장식 저장 실패: ' + (err && err.message ? err.message : err), 'error');
}).always(function () {
setButtonsDisabled(false);
});
}, function () {
setButtonsDisabled(false);
setStatus('mediawiki.api 로드 실패', 'error');
});
}
/* -----------------------------------------
Decoration layer editor v2
----------------------------------------- */
function decoClone(value) {
try {
return JSON.parse(JSON.stringify(value || {}));
} catch (err) {
return {};
}
}
function decoCurrentPageKey() {
var raw = String(mw.config.get('wgPageName') || '').trim();
var normalized = normalizePageName(raw);
return normalized || raw || '대문';
}
function decoNormalizePage(value) {
return normalizePageName(value || '');
}
function decoMatchesCurrentPage(entry) {
var page = decoCurrentPageKey();
var underscored = page.replace(/ /g, '_');
var pages = entry && entry.pages;
var target = entry && entry.page;
var i;
if (!entry || typeof entry !== 'object') return false;
if (entry.global === true || entry.global === 'true' || entry.global === 1 || entry.global === '1') return true;
if (!target && !pages) return true;
if (Array.isArray(pages)) {
for (i = 0; i < pages.length; i += 1) {
if (decoNormalizePage(pages[i]) === page || String(pages[i] || '') === underscored) return true;
}
}
target = decoNormalizePage(target);
return target === page || target === underscored;
}
function decoRegistryList(registry) {
if (!registry || typeof registry !== 'object') return [];
if (Array.isArray(registry)) return registry;
if (!Array.isArray(registry.decorations)) registry.decorations = [];
return registry.decorations;
}
function decoMakeRegistry(raw) {
var registry = raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : { version: 1, decorations: [] };
if (!Array.isArray(registry.decorations)) registry.decorations = [];
if (!registry.version) registry.version = 1;
return registry;
}
function decoMakeId(label) {
var base = String(label || decoCurrentPageKey() || 'decoration')
.replace(/[^a-zA-Z0-9가-힣_-]+/g, '-')
.replace(/^-+|-+$/g, '') || 'decoration';
return base + '-' + Date.now();
}
function decoIsGridEnabled() {
return localStorage.getItem('dev-tools-decoration-grid-enabled') !== 'false';
}
function decoSetGridEnabled(enabled) {
localStorage.setItem('dev-tools-decoration-grid-enabled', enabled ? 'true' : 'false');
$('#dev-tools-decoration-grid-toggle')
.attr('data-grid', enabled ? '1' : '0')
.text(enabled ? 'GRID ON' : 'GRID OFF');
renderDecorationGridOverlay();
}
function decorationLayerList() {
return decoRegistryList(decorationEditor.workingRegistry || decorationEditor.registry || { decorations: [] });
}
function decorationVisibleLayerList() {
return decorationLayerList().filter(function (entry) {
return decoMatchesCurrentPage(entry);
}).sort(function (a, b) {
return (Number(b.zIndex) || 0) - (Number(a.zIndex) || 0);
});
}
function getDecorationSelectedLayer() {
var list = decorationLayerList();
var id = decorationEditor.selectedId;
var i;
for (i = 0; i < list.length; i += 1) {
if (list[i] && list[i].id === id) return list[i];
}
return null;
}
function setDecorationEditorModeUi() {
var editing = !!decorationEditor.editMode;
var hasSelected = !!getDecorationSelectedLayer();
$('.dev-tools-decoration-section').toggleClass('is-editing', editing);
$('body').toggleClass('clbi-decoration-edit-mode', editing);
$('#dev-tools-decoration-mode').text(editing ? 'EDIT MODE' : 'VIEW MODE');
$('#dev-tools-decoration-edit-toggle').text(editing ? 'EDITING' : 'EDIT MODE').toggleClass('is-active', editing);
$('#dev-tools-decoration-save, #dev-tools-decoration-cancel, #dev-tools-decoration-add').prop('disabled', !editing);
$('#dev-tools-decoration-delete, #dev-tools-decoration-target-continent, #dev-tools-decoration-center, #dev-tools-decoration-fit-width').prop('disabled', !editing || !hasSelected);
$('#dev-tools-decoration-label, #dev-tools-decoration-src, #dev-tools-decoration-target, #dev-tools-decoration-era, #dev-tools-decoration-continent, #dev-tools-decoration-x, #dev-tools-decoration-y, #dev-tools-decoration-width, #dev-tools-decoration-height, #dev-tools-decoration-opacity, #dev-tools-decoration-z').prop('disabled', !editing || !hasSelected);
}
function renderDecorationLayerList() {
var list = decorationVisibleLayerList();
var html;
$('#dev-tools-decoration-page').text('PAGE: ' + decoCurrentPageKey());
refreshDecorationContinentReadout();
if (!list.length) {
html = '<div class="dev-tools-decoration-empty">이 문서에 표시되는 장식 레이어가 없습니다.</div>';
} else {
html = list.map(function (entry) {
var selected = entry.id === decorationEditor.selectedId;
var title = entry.label || entry.name || entry.id || 'untitled';
var meta = [];
if (entry.era) meta.push(String(entry.era));
if (entry.continent) meta.push(String(entry.continent));
meta.push('z ' + (entry.zIndex == null ? 0 : entry.zIndex));
return '' +
'<button type="button" class="dev-tools-decoration-layer' + (selected ? ' is-selected' : '') + '" data-decoration-id="' + escapeHtml(entry.id || '') + '">' +
'<span class="dev-tools-decoration-layer-name">' + escapeHtml(title) + '</span>' +
'<span class="dev-tools-decoration-layer-meta">' + escapeHtml(meta.join(' / ')) + '</span>' +
'</button>';
}).join('');
}
$('#dev-tools-decoration-layer-list').html(html);
setDecorationEditorModeUi();
}
function writeDecorationLayerForm(entry) {
entry = entry || {};
$('#dev-tools-decoration-id').val(entry.id || '');
$('#dev-tools-decoration-label').val(entry.label || entry.name || entry.id || '');
$('#dev-tools-decoration-target').val(entry.target || getDefaultDecorationTarget());
$('#dev-tools-decoration-era').val(entry.era || '');
$('#dev-tools-decoration-continent').val(entry.continent || '');
$('#dev-tools-decoration-src').val(entry.src || '');
$('#dev-tools-decoration-x').val(entry.x == null ? 0 : Math.round(Number(entry.x) || 0));
$('#dev-tools-decoration-y').val(entry.y == null ? 0 : Math.round(Number(entry.y) || 0));
$('#dev-tools-decoration-width').val(entry.width == null ? 1372 : Math.max(1, Math.round(Number(entry.width) || 1372)));
$('#dev-tools-decoration-height').val(entry.height == null ? 'auto' : Math.max(1, Math.round(Number(entry.height) || 1)));
$('#dev-tools-decoration-opacity').val(entry.opacity == null ? 1 : Math.max(0, Math.min(1, Number(entry.opacity) || 0)));
$('#dev-tools-decoration-z').val(entry.zIndex == null ? 0 : Math.round(Number(entry.zIndex) || 0));
refreshDecorationScopeReadout();
setDecorationEditorModeUi();
}
function readDecorationLayerForm() {
var height = readDecorationHeight();
var label = String($('#dev-tools-decoration-label').val() || '').trim();
var entry = {
id: String($('#dev-tools-decoration-id').val() || '').trim() || decoMakeId(label),
label: label,
page: decoCurrentPageKey(),
target: String($('#dev-tools-decoration-target').val() || '').trim() || getDefaultDecorationTarget(),
era: String($('#dev-tools-decoration-era').val() || '').trim(),
continent: String($('#dev-tools-decoration-continent').val() || '').trim(),
src: String($('#dev-tools-decoration-src').val() || '').trim(),
x: Math.round(readDecorationNumber('#dev-tools-decoration-x', 0)),
y: Math.round(readDecorationNumber('#dev-tools-decoration-y', 0)),
width: Math.max(1, Math.round(readDecorationNumber('#dev-tools-decoration-width', 1372))),
opacity: Math.max(0, Math.min(1, readDecorationNumber('#dev-tools-decoration-opacity', 1))),
zIndex: Math.round(readDecorationNumber('#dev-tools-decoration-z', 0)),
pointerEvents: 'none',
enabled: true
};
if (!entry.label) delete entry.label;
if (height) entry.height = height;
if (!entry.era) delete entry.era;
if (!entry.continent) delete entry.continent;
return entry;
}
function replaceSelectedDecorationFromForm() {
var entry;
var list;
var i;
if (!decorationEditor.editMode || !decorationEditor.selectedId) return null;
entry = readDecorationLayerForm();
list = decorationLayerList();
for (i = 0; i < list.length; i += 1) {
if (list[i] && list[i].id === decorationEditor.selectedId) {
list[i] = entry;
decorationEditor.selectedId = entry.id;
return entry;
}
}
return null;
}
function selectDecorationLayer(id, silent) {
var list = decorationLayerList();
var i;
var entry = null;
id = String(id || '');
for (i = 0; i < list.length; i += 1) {
if (list[i] && list[i].id === id) {
entry = list[i];
break;
}
}
if (!entry) return false;
decorationEditor.selectedId = entry.id;
writeDecorationLayerForm(entry);
renderDecorationLayerList();
markDecorationSelectedPreview();
renderDecorationGridOverlay();
if (!silent) setStatus('레이어 선택: ' + (entry.label || entry.id), 'ok');
return true;
}
function selectFirstVisibleDecorationLayer() {
var list = decorationVisibleLayerList();
if (list.length) {
selectDecorationLayer(list[0].id, true);
} else {
decorationEditor.selectedId = '';
writeDecorationLayerForm({});
renderDecorationLayerList();
}
}
function loadDecorationLayerRegistry(callback) {
withApi(function (api) {
fetchDecorationRegistry(api).then(function (registry) {
decorationEditor.registry = decoMakeRegistry(registry);
if (!decorationEditor.editMode) {
decorationEditor.workingRegistry = decoClone(decorationEditor.registry);
}
renderDecorationLayerList();
if (!getDecorationSelectedLayer()) selectFirstVisibleDecorationLayer();
if (typeof callback === 'function') callback(decorationEditor.registry);
}).catch(function (err) {
setStatus('장식 목록 로드 실패: ' + (err && err.message ? err.message : err), 'error');
});
}, function () {
setStatus('mediawiki.api 로드 실패', 'error');
});
}
function writeDecorationDefaults() {
decoSetGridEnabled(decoIsGridEnabled());
writeDecorationLayerForm({
id: decoMakeId('decoration'),
page: decoCurrentPageKey(),
target: getDefaultDecorationTarget(),
src: localStorage.getItem('dev-tools-decoration-last-src') || '',
x: 0,
y: 0,
width: 1372,
opacity: 1,
zIndex: 0
});
loadDecorationLayerRegistry();
}
function clearDecorationWorkPreviews() {
Array.prototype.forEach.call(document.querySelectorAll('.dev-tools-decoration-work-preview, .dev-tools-decoration-grid-overlay'), function (node) {
if (node.parentNode) node.parentNode.removeChild(node);
});
decorationEditor.previewMap = {};
decorationEditor.gridTarget = null;
}
function applyDecorationLayerStyle(img, entry) {
var h = Number(entry.height);
img.src = normalizeDecorationSrc(entry.src);
img.style.left = Math.round(Number(entry.x) || 0) + 'px';
img.style.top = Math.round(Number(entry.y) || 0) + 'px';
img.style.width = Math.max(1, Math.round(Number(entry.width) || 1)) + 'px';
img.style.height = Number.isFinite(h) && h > 0 ? Math.round(h) + 'px' : 'auto';
img.style.opacity = String(entry.opacity == null ? 1 : entry.opacity);
img.style.zIndex = String(Math.round(Number(entry.zIndex) || 0));
img.style.pointerEvents = 'auto';
img.style.cursor = 'move';
img.style.touchAction = 'none';
img.setAttribute('data-decoration-id', entry.id || '');
}
function renderDecorationWorkPreviews() {
var list;
var map = {};
var i;
var entry;
var target;
var img;
if (!decorationEditor.editMode) return;
clearDecorationWorkPreviews();
list = decorationLayerList();
for (i = 0; i < list.length; i += 1) {
entry = list[i];
if (!entry || entry.enabled === false || !entry.src || !decoMatchesCurrentPage(entry)) continue;
target = getDecorationTargetElement(entry);
if (!target) continue;
ensureDecorationHost(target);
img = document.createElement('img');
img.className = 'wiki-decoration dev-tools-decoration-work-preview';
img.setAttribute('aria-hidden', 'true');
img.setAttribute('alt', '');
img.setAttribute('tabindex', '0');
img.draggable = false;
applyDecorationLayerStyle(img, entry);
target.appendChild(img);
map[entry.id] = img;
}
decorationEditor.previewMap = map;
markDecorationSelectedPreview();
renderDecorationGridOverlay();
bindDecorationLayerPreviewDrag();
}
function markDecorationSelectedPreview() {
var map = decorationEditor.previewMap || {};
Object.keys(map).forEach(function (id) {
if (map[id]) map[id].classList.toggle('is-selected', id === decorationEditor.selectedId);
});
}
function renderDecorationGridOverlay() {
var entry;
var target;
var overlay;
clearDecorationGridOverlay();
if (!decorationEditor.editMode || !decoIsGridEnabled()) return;
entry = getDecorationSelectedLayer();
if (!entry) return;
target = getDecorationTargetElement(entry);
if (!target) return;
ensureDecorationHost(target);
overlay = document.createElement('div');
overlay.className = 'dev-tools-decoration-grid-overlay';
overlay.setAttribute('aria-hidden', 'true');
target.appendChild(overlay);
decorationEditor.gridTarget = target;
}
function clearDecorationGridOverlay() {
Array.prototype.forEach.call(document.querySelectorAll('.dev-tools-decoration-grid-overlay'), function (node) {
if (node.parentNode) node.parentNode.removeChild(node);
});
decorationEditor.gridTarget = null;
}
function getDecorationPreviewForSelected() {
var map = decorationEditor.previewMap || {};
return map[decorationEditor.selectedId] || null;
}
function snapDecorationPosition(x, y, entry) {
var target;
var img;
var targetW;
var targetH;
var imgW;
var imgH;
var centerX;
var centerY;
var threshold = 7;
if (!decoIsGridEnabled()) return { x: x, y: y };
target = getDecorationTargetElement(entry);
img = getDecorationPreviewForSelected();
if (!target || !img) return { x: x, y: y };
targetW = target.clientWidth || target.offsetWidth || 0;
targetH = target.clientHeight || target.offsetHeight || 0;
imgW = img.offsetWidth || Number(entry.width) || 0;
imgH = img.offsetHeight || Number(entry.height) || 0;
if (targetW > 0 && imgW > 0) {
centerX = Math.round((targetW - imgW) / 2);
if (Math.abs(x - centerX) <= threshold) x = centerX;
}
if (targetH > 0 && imgH > 0) {
centerY = Math.round((targetH - imgH) / 2);
if (Math.abs(y - centerY) <= threshold) y = centerY;
}
return { x: Math.round(x), y: Math.round(y) };
}
function setDecorationLayerPosition(x, y) {
var entry;
var snapped;
if (!decorationEditor.editMode) return;
entry = getDecorationSelectedLayer();
if (!entry) return;
snapped = snapDecorationPosition(x, y, entry);
entry.x = snapped.x;
entry.y = snapped.y;
$('#dev-tools-decoration-x').val(entry.x);
$('#dev-tools-decoration-y').val(entry.y);
renderDecorationWorkPreviews();
}
function bindDecorationLayerPreviewDrag() {
var map = decorationEditor.previewMap || {};
Object.keys(map).forEach(function (id) {
var img = map[id];
if (!img || img.getAttribute('data-dev-tools-layer-bound') === '1') return;
img.setAttribute('data-dev-tools-layer-bound', '1');
img.addEventListener('pointerdown', function (event) {
var entry;
var target;
if (!decorationEditor.editMode) return;
selectDecorationLayer(id, true);
entry = getDecorationSelectedLayer();
target = getDecorationTargetElement(entry);
if (!entry || !target) return;
event.preventDefault();
event.stopPropagation();
decorationEditor.drag = {
id: id,
pointerId: event.pointerId,
startClientX: event.clientX,
startClientY: event.clientY,
startX: Number(entry.x) || 0,
startY: Number(entry.y) || 0,
axis: ''
};
img.setPointerCapture && img.setPointerCapture(event.pointerId);
img.classList.add('is-dragging');
});
img.addEventListener('wheel', function (event) {
var entry;
var width;
if (!decorationEditor.editMode || (!event.ctrlKey && !event.altKey)) return;
event.preventDefault();
selectDecorationLayer(id, true);
entry = getDecorationSelectedLayer();
if (!entry) return;
width = Math.max(1, Math.round((Number(entry.width) || 1) + (event.deltaY < 0 ? 8 : -8) * (event.shiftKey ? 4 : 1)));
entry.width = width;
$('#dev-tools-decoration-width').val(width);
renderDecorationWorkPreviews();
}, { passive: false });
});
}
function updateDecorationDragMove(event) {
var drag = decorationEditor.drag;
var entry;
var target;
var scale;
var dx;
var dy;
var x;
var y;
if (!drag || !decorationEditor.editMode) return;
entry = getDecorationSelectedLayer();
target = getDecorationTargetElement(entry);
if (!entry || !target) return;
scale = getDecorationTargetScale(target);
dx = (event.clientX - drag.startClientX) / (scale.x || 1);
dy = (event.clientY - drag.startClientY) / (scale.y || 1);
if (event.shiftKey) {
if (!drag.axis && Math.max(Math.abs(dx), Math.abs(dy)) >= 4) {
drag.axis = Math.abs(dx) >= Math.abs(dy) ? 'x' : 'y';
}
if (drag.axis === 'x') dy = 0;
if (drag.axis === 'y') dx = 0;
} else {
drag.axis = '';
}
x = drag.startX + dx;
y = drag.startY + dy;
setDecorationLayerPosition(x, y);
}
function stopDecorationDrag() {
var map = decorationEditor.previewMap || {};
Object.keys(map).forEach(function (id) {
if (map[id]) map[id].classList.remove('is-dragging');
});
decorationEditor.drag = null;
}
function enterDecorationEditMode() {
if (decorationEditor.editMode) return;
setButtonsDisabled(true);
setStatus('장식 에딧 모드 준비 중...');
withApi(function (api) {
fetchDecorationRegistry(api).then(function (registry) {
decorationEditor.registry = decoMakeRegistry(registry);
decorationEditor.savedRegistry = decoClone(decorationEditor.registry);
decorationEditor.workingRegistry = decoClone(decorationEditor.registry);
decorationEditor.editMode = true;
if (window.CLBI_DECORATIONS && typeof window.CLBI_DECORATIONS.clear === 'function') {
window.CLBI_DECORATIONS.clear(document);
}
if (!getDecorationSelectedLayer()) selectFirstVisibleDecorationLayer();
renderDecorationLayerList();
renderDecorationWorkPreviews();
setDecorationEditorModeUi();
setStatus('DECORATION EDIT MODE', 'ok');
}).catch(function (err) {
setStatus('장식 에딧 모드 시작 실패: ' + (err && err.message ? err.message : err), 'error');
}).always(function () {
setButtonsDisabled(false);
setDecorationEditorModeUi();
});
}, function () {
setButtonsDisabled(false);
setDecorationEditorModeUi();
setStatus('mediawiki.api 로드 실패', 'error');
});
}
function cancelDecorationLayerEditor() {
if (!decorationEditor.editMode) return;
decorationEditor.workingRegistry = decoClone(decorationEditor.savedRegistry || decorationEditor.registry || { decorations: [] });
decorationEditor.editMode = false;
clearDecorationWorkPreviews();
setDecorationEditorModeUi();
renderDecorationLayerList();
if (window.CLBI_DECORATIONS && typeof window.CLBI_DECORATIONS.reload === 'function') {
window.CLBI_DECORATIONS.reload();
}
setStatus('장식 편집 취소 / 저장값 복구', 'ok');
}
function toggleDecorationEditMode() {
if (decorationEditor.editMode) {
cancelDecorationLayerEditor();
} else {
enterDecorationEditMode();
}
}
function addDecorationLayer() {
var list;
var entry;
var target;
if (!decorationEditor.editMode) return;
list = decorationLayerList();
target = getDefaultDecorationScopedTarget ? getDefaultDecorationScopedTarget() : getDefaultDecorationTarget();
entry = {
id: decoMakeId('decoration'),
label: 'Decoration ' + (list.length + 1),
page: decoCurrentPageKey(),
target: target || getDefaultDecorationTarget(),
era: getActiveDecorationEra() || '',
continent: getActiveDecorationContinent() || '',
src: localStorage.getItem('dev-tools-decoration-last-src') || '',
x: 0,
y: 0,
width: Math.max(1, (document.querySelector(target || getDefaultDecorationTarget()) || {}).clientWidth || 1372),
opacity: 1,
zIndex: list.length,
pointerEvents: 'none',
enabled: true
};
if (!entry.era) delete entry.era;
if (!entry.continent) delete entry.continent;
list.push(entry);
decorationEditor.selectedId = entry.id;
writeDecorationLayerForm(entry);
renderDecorationLayerList();
renderDecorationWorkPreviews();
setStatus('새 장식 레이어 추가', 'ok');
}
function deleteDecorationLayer() {
var list;
var id;
var next;
if (!decorationEditor.editMode || !decorationEditor.selectedId) return;
if (!window.confirm('선택한 장식 레이어를 삭제할까요? 저장 전까지는 CANCEL로 되돌릴 수 있습니다.')) return;
list = decorationLayerList();
id = decorationEditor.selectedId;
decorationEditor.workingRegistry.decorations = list.filter(function (entry) {
return !entry || entry.id !== id;
});
next = decorationVisibleLayerList()[0];
decorationEditor.selectedId = next ? next.id : '';
if (next) writeDecorationLayerForm(next);
else writeDecorationLayerForm({});
renderDecorationLayerList();
renderDecorationWorkPreviews();
setStatus('레이어 삭제 대기 / SAVE 필요', 'ok');
}
function updateDecorationLayerFromForm() {
var entry;
if (!decorationEditor.editMode) return;
entry = replaceSelectedDecorationFromForm();
if (!entry) return;
if (entry.src) localStorage.setItem('dev-tools-decoration-last-src', entry.src);
renderDecorationLayerList();
renderDecorationWorkPreviews();
}
function centerDecorationLayer() {
var entry;
var target;
var img;
var x;
var y;
if (!decorationEditor.editMode) return;
updateDecorationLayerFromForm();
entry = getDecorationSelectedLayer();
target = getDecorationTargetElement(entry);
img = getDecorationPreviewForSelected();
if (!entry || !target) return;
x = Math.round(((target.clientWidth || target.offsetWidth || 0) - (img ? img.offsetWidth : Number(entry.width) || 0)) / 2);
y = Math.round(((target.clientHeight || target.offsetHeight || 0) - (img ? img.offsetHeight : Number(entry.height) || 0)) / 2);
setDecorationLayerPosition(x, y);
}
function fitDecorationLayerWidth() {
var entry;
var target;
if (!decorationEditor.editMode) return;
updateDecorationLayerFromForm();
entry = getDecorationSelectedLayer();
target = getDecorationTargetElement(entry);
if (!entry || !target) return;
entry.x = 0;
entry.width = Math.max(1, target.clientWidth || target.offsetWidth || Number(entry.width) || 1);
$('#dev-tools-decoration-x').val(0);
$('#dev-tools-decoration-width').val(entry.width);
renderDecorationWorkPreviews();
}
function saveDecorationEditor() {
var registry;
if (!decorationEditor.editMode) return;
updateDecorationLayerFromForm();
registry = decoMakeRegistry(decoClone(decorationEditor.workingRegistry || { decorations: [] }));
registry.updatedAt = new Date().toISOString();
withApi(function (api) {
setButtonsDisabled(true);
setStatus('장식 레이어 저장 중...');
api.postWithToken('csrf', {
action: 'edit',
title: DECORATIONS_TITLE,
text: JSON.stringify(registry, null, 2) + '\n',
summary: 'Update decoration layers',
contentmodel: 'json',
format: 'json',
formatversion: 2
}).then(function () {
decorationEditor.registry = decoClone(registry);
decorationEditor.savedRegistry = decoClone(registry);
decorationEditor.workingRegistry = decoClone(registry);
decorationEditor.editMode = false;
clearDecorationWorkPreviews();
renderDecorationLayerList();
setDecorationEditorModeUi();
if (window.CLBI_DECORATIONS && typeof window.CLBI_DECORATIONS.reload === 'function') {
window.CLBI_DECORATIONS.reload();
}
setStatus('장식 레이어 저장 완료', 'ok');
}).catch(function (err) {
setStatus('장식 레이어 저장 실패: ' + (err && err.message ? err.message : err), 'error');
}).always(function () {
setButtonsDisabled(false);
setDecorationEditorModeUi();
});
}, function () {
setButtonsDisabled(false);
setDecorationEditorModeUi();
setStatus('mediawiki.api 로드 실패', 'error');
});
}
function targetActiveDecorationContinent() {
var entry;
var era = getActiveDecorationEra();
var continent = getActiveDecorationContinent();
var selector = getActiveDecorationScopeSelector();
if (!decorationEditor.editMode) return;
entry = getDecorationSelectedLayer();
if (!entry) return;
$('#dev-tools-decoration-era').val(era || '');
$('#dev-tools-decoration-continent').val(continent || '');
$('#dev-tools-decoration-target').val(selector || getDefaultDecorationTarget());
updateDecorationLayerFromForm();
setStatus('대상 scope 지정: ' + (era || 'PAGE') + (continent ? (' / ' + continent) : ''), 'ok');
}
function refreshDecorationContinentReadout() {
var era = getActiveDecorationEra();
var continent = getActiveDecorationContinent();
$('#dev-tools-decoration-continent-readout').text('ERA: ' + (era || 'NONE') + ' / CONTINENT: ' + (continent || 'NONE'));
}
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 deleteRenameRedirectPage(api, fromTitle, expectedToTitle, reason) {
fromTitle = normalizeMoveTitle(fromTitle);
expectedToTitle = normalizeMoveTitle(expectedToTitle);
if (!fromTitle) return Promise.resolve({ deleted: false, skipped: 'empty' });
return resolveRenameRedirectTarget(api, fromTitle).then(function (redirectTo) {
redirectTo = normalizeMoveTitle(redirectTo);
if (!redirectTo) {
return { deleted: false, skipped: 'not_redirect' };
}
if (expectedToTitle && normalizeMoveCompare(redirectTo) !== normalizeMoveCompare(expectedToTitle)) {
throw new Error('기존 이름이 새 이름이 아니라 다른 대상으로 redirect됩니다: ' + redirectTo);
}
setStatus('기존 redirect 문서 제거 중... ' + fromTitle);
return api.postWithToken('csrf', {
action: 'delete',
title: fromTitle,
reason: reason || ('이름 변경 후 남은 redirect 제거: ' + fromTitle + ' → ' + redirectTo),
format: 'json',
formatversion: 2,
errorformat: 'plaintext'
}).then(function () {
return { deleted: true, target: redirectTo };
});
});
}
function runRenameReferenceCleanupOnly() {
var fromTitle = normalizeMoveTitle($('#dev-tools-rename-from').val());
var toTitle = normalizeMoveTargetTitle(fromTitle, $('#dev-tools-rename-to').val());
var shouldDeleteOldRedirect = $('#dev-tools-rename-delete-old').prop('checked');
var reason = String($('#dev-tools-rename-reason').val() || '이름 변경').trim();
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) {
if (!shouldDeleteOldRedirect) {
return { movedTo: resolvedToTitle, referenceResult: referenceResult, deleteResult: null };
}
return deleteRenameRedirectPage(api, fromTitle, resolvedToTitle, '이름 변경 후 기존 redirect 제거: ' + fromTitle + ' → ' + resolvedToTitle).then(function (deleteResult) {
return { movedTo: resolvedToTitle, referenceResult: referenceResult, deleteResult: deleteResult };
});
});
}).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 + '개';
if (result.deleteResult && result.deleteResult.deleted) message += ' / 기존 redirect 삭제';
if (result.deleteResult && result.deleteResult.skipped === 'not_redirect') message += ' / 기존 redirect 없음';
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 shouldDeleteOldRedirect = $('#dev-tools-rename-delete-old').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기존 이름 redirect를 남깁니다.' : '\n\n기존 이름 redirect를 남기지 않습니다.'))) {
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);
var cleanupChain = shouldUpdateReferences ? updateRenameReferences(api, fromTitle, movedTo || toTitle) : Promise.resolve(null);
return cleanupChain.then(function (referenceResult) {
if (keepLegacy || !shouldDeleteOldRedirect) {
return { movedTo: movedTo, referenceResult: referenceResult, deleteResult: null };
}
return deleteRenameRedirectPage(api, fromTitle, movedTo || toTitle, '이름 변경 후 기존 redirect 제거: ' + fromTitle + ' → ' + (movedTo || toTitle)).then(function (deleteResult) {
return { movedTo: movedTo, referenceResult: referenceResult, deleteResult: deleteResult };
});
});
}).then(function (result) {
var movedTo = result && result.movedTo ? result.movedTo : toTitle;
var referenceResult = result && result.referenceResult;
var deleteResult = result && result.deleteResult;
var message = '이름 변경 완료: ' + movedTo;
if (referenceResult) {
message += ' / 참조 ' + referenceResult.changed + '개 정리';
if (referenceResult.failed) message += ' / 실패 ' + referenceResult.failed + '개';
}
if (deleteResult && deleteResult.deleted) message += ' / 기존 redirect 삭제';
if (deleteResult && deleteResult.skipped === 'not_redirect') message += ' / 기존 redirect 없음';
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 normalizeSvgFileTitle(value) {
var title = String(value || '')
.replace(/_/g, ' ')
.replace(/^\s+|\s+$/g, '');
if (!title) return '';
if (/^(file|파일):/i.test(title)) return title.replace(/^(file|파일):\s*/i, 'File:');
return 'File:' + title;
}
function svgFileUploadName(title) {
return normalizeSvgFileTitle(title).replace(/^File:/i, '').trim();
}
function parseSvgTitleList(value) {
var seen = {};
var list = [];
String(value || '').split(/[\n,]+/).forEach(function (part) {
var title = normalizeSvgFileTitle(part);
var key = title.toLowerCase();
if (!title || seen[key]) return;
seen[key] = true;
list.push(title);
});
return list;
}
function renderSvgNormalizeLog(items) {
var html;
if (!items || !items.length) {
$('#dev-tools-svg-list').empty();
return;
}
html = items.map(function (item) {
var cls = item.ok ? 'dev-tools-package-row' : 'dev-tools-package-row dev-tools-package-skipped';
var status = item.message || '';
return '<div class="' + cls + '">' +
'<span class="dev-tools-package-source">' + escapeHtml(item.title || '') + '</span>' +
'<span class="dev-tools-package-arrow">' + (item.ok ? '✓' : '×') + '</span>' +
'<span class="dev-tools-package-target">' + escapeHtml(status) + '</span>' +
'</div>';
}).join('');
$('#dev-tools-svg-list').html(html);
}
function fillCurrentSvgFileTitle() {
var ns = Number(mw.config.get('wgNamespaceNumber'));
var title = mw.config.get('wgPageName') || '';
if (ns !== 6 || !title) {
setStatus('현재 문서가 파일 문서가 아닙니다.', 'error');
return;
}
title = title.replace(/_/g, ' ');
if (!/\.svg$/i.test(title)) {
setStatus('현재 파일이 SVG가 아닙니다.', 'error');
return;
}
$('#dev-tools-svg-normalize-titles').val(normalizeSvgFileTitle(title));
setStatus('현재 SVG 파일명을 입력했습니다.', 'ok');
}
function fillRecentFlagSvgTitles() {
setButtonsDisabled(true);
setStatus('최근 Flag SVG 목록을 가져오는 중...');
withApi(function (api) {
api.get({
action: 'query',
list: 'allimages',
aiprefix: 'Flag-',
aifrom: 'Flag-',
aiprop: 'timestamp|mime|url',
ailimit: 50,
format: 'json',
formatversion: 2,
_: Date.now()
}).then(function (data) {
var images = data && data.query ? data.query.allimages || [] : [];
var titles = images.filter(function (image) {
var name = image && image.name ? String(image.name) : '';
return /\.svg$/i.test(name);
}).map(function (image) {
return 'File:' + image.name;
});
$('#dev-tools-svg-normalize-titles').val(titles.join('\n'));
setStatus('최근 Flag SVG ' + titles.length + '개를 입력했습니다.', titles.length ? 'ok' : 'error');
}).catch(function (err) {
setStatus('최근 Flag SVG 목록 로드 실패: ' + (err && err.message ? err.message : err), 'error');
}).finally(function () {
setButtonsDisabled(false);
});
}, function () {
setButtonsDisabled(false);
setStatus('mediawiki.api 로드 실패', 'error');
});
}
function parseSvgNumber(value) {
var match = String(value == null ? '' : value).trim().match(/^-?\d+(?:\.\d+)?/);
return match ? parseFloat(match[0]) : NaN;
}
function parseSvgViewBox(root) {
var viewBox = root.getAttribute('viewBox') || root.getAttribute('viewbox') || '';
var parts = viewBox.trim().split(/[\s,]+/).map(function (part) { return parseFloat(part); });
var width;
var height;
if (parts.length === 4 && parts.every(function (value) { return Number.isFinite(value); }) && parts[2] > 0 && parts[3] > 0) {
return { x: parts[0], y: parts[1], width: parts[2], height: parts[3] };
}
width = parseSvgNumber(root.getAttribute('width'));
height = parseSvgNumber(root.getAttribute('height'));
if (Number.isFinite(width) && Number.isFinite(height) && width > 0 && height > 0) {
return { x: 0, y: 0, width: width, height: height };
}
return null;
}
function formatSvgNumber(value) {
var rounded = Math.round(value * 1000) / 1000;
if (Math.abs(rounded) < 0.0005) rounded = 0;
return String(rounded).replace(/\.0+$/, '').replace(/(\.\d*?)0+$/, '$1');
}
function serializeSvgDocument(doc, hadXmlDecl) {
var text = new XMLSerializer().serializeToString(doc);
if (hadXmlDecl && text.indexOf('<?xml') !== 0) {
text = '<?xml version="1.0" encoding="UTF-8"?>\n' + text;
}
return text + (text.slice(-1) === '\n' ? '' : '\n');
}
function renderSvgTextToCanvas(svgText, viewport) {
return new Promise(function (resolve, reject) {
var blob = new Blob([svgText], { type: 'image/svg+xml;charset=utf-8' });
var url = URL.createObjectURL(blob);
var img = new Image();
var maxSide = 1600;
var scale = Math.min(maxSide / Math.max(viewport.width, viewport.height), 4);
var width = Math.max(1, Math.round(viewport.width * scale));
var height = Math.max(1, Math.round(viewport.height * scale));
var canvas = document.createElement('canvas');
var ctx;
canvas.width = width;
canvas.height = height;
ctx = canvas.getContext('2d');
img.onload = function () {
try {
ctx.clearRect(0, 0, width, height);
ctx.drawImage(img, 0, 0, width, height);
URL.revokeObjectURL(url);
resolve({ canvas: canvas, width: width, height: height });
} catch (err) {
URL.revokeObjectURL(url);
reject(err);
}
};
img.onerror = function () {
URL.revokeObjectURL(url);
reject(new Error('SVG 렌더링 실패'));
};
img.src = url;
});
}
function findCanvasAlphaBounds(canvas) {
var ctx = canvas.getContext('2d');
var width = canvas.width;
var height = canvas.height;
var data = ctx.getImageData(0, 0, width, height).data;
var minX = width;
var minY = height;
var maxX = -1;
var maxY = -1;
var x;
var y;
var alpha;
var index;
for (y = 0; y < height; y += 1) {
for (x = 0; x < width; x += 1) {
index = (y * width + x) * 4 + 3;
alpha = data[index];
if (alpha > 3) {
if (x < minX) minX = x;
if (y < minY) minY = y;
if (x > maxX) maxX = x;
if (y > maxY) maxY = y;
}
}
}
if (maxX < minX || maxY < minY) return null;
return { minX: minX, minY: minY, maxX: maxX, maxY: maxY };
}
function cropSvgTextToRenderedPixels(svgText) {
var parser = new DOMParser();
var doc = parser.parseFromString(svgText, 'image/svg+xml');
var root = doc.documentElement;
var parserError = doc.querySelector('parsererror');
var viewport;
var hadXmlDecl = /^\s*<\?xml/i.test(svgText);
if (parserError || !root || String(root.nodeName || '').toLowerCase() !== 'svg') {
return Promise.reject(new Error('SVG 파싱 실패'));
}
viewport = parseSvgViewBox(root);
if (!viewport) return Promise.reject(new Error('viewBox/width/height를 읽을 수 없음'));
return renderSvgTextToCanvas(svgText, viewport).then(function (rendered) {
var bounds = findCanvasAlphaBounds(rendered.canvas);
var padX;
var padY;
var cropX;
var cropY;
var cropW;
var cropH;
var oldViewBox;
var newViewBox;
var style;
if (!bounds) throw new Error('보이는 픽셀이 없음');
padX = Math.max(1, Math.round(rendered.width * 0.002));
padY = Math.max(1, Math.round(rendered.height * 0.002));
bounds.minX = Math.max(0, bounds.minX - padX);
bounds.minY = Math.max(0, bounds.minY - padY);
bounds.maxX = Math.min(rendered.width - 1, bounds.maxX + padX);
bounds.maxY = Math.min(rendered.height - 1, bounds.maxY + padY);
cropX = viewport.x + (bounds.minX / rendered.width) * viewport.width;
cropY = viewport.y + (bounds.minY / rendered.height) * viewport.height;
cropW = ((bounds.maxX - bounds.minX + 1) / rendered.width) * viewport.width;
cropH = ((bounds.maxY - bounds.minY + 1) / rendered.height) * viewport.height;
if (!(cropW > 0 && cropH > 0)) throw new Error('계산된 크롭 범위가 잘못됨');
oldViewBox = [viewport.x, viewport.y, viewport.width, viewport.height].map(formatSvgNumber).join(' ');
newViewBox = [cropX, cropY, cropW, cropH].map(formatSvgNumber).join(' ');
root.setAttribute('viewBox', newViewBox);
root.setAttribute('width', formatSvgNumber(cropW));
root.setAttribute('height', formatSvgNumber(cropH));
root.setAttribute('overflow', 'hidden');
root.setAttribute('preserveAspectRatio', 'xMidYMid meet');
style = root.getAttribute('style') || '';
if (!/overflow\s*:/i.test(style)) {
root.setAttribute('style', (style ? style.replace(/;?\s*$/, '; ') : '') + 'overflow:hidden;');
}
return {
text: serializeSvgDocument(doc, hadXmlDecl),
oldViewBox: oldViewBox,
newViewBox: newViewBox,
cropWidth: cropW,
cropHeight: cropH,
changed: oldViewBox !== newViewBox
};
});
}
function fetchSvgFileText(api, title) {
title = normalizeSvgFileTitle(title);
return api.get({
action: 'query',
prop: 'imageinfo',
titles: normalizeApiTitle(title),
iiprop: 'url|mime',
format: 'json',
formatversion: 2,
_: Date.now()
}).then(function (data) {
var pages = data && data.query ? data.query.pages || [] : [];
var page = pages[0];
var info = page && page.imageinfo ? page.imageinfo[0] : null;
var url = info && info.url ? info.url : '';
if (!page || page.missing) throw new Error('파일 문서 없음');
if (!url) throw new Error('파일 URL 없음');
if (!/\.svg(?:\?|$)/i.test(url) && info.mime !== 'image/svg+xml') throw new Error('SVG 파일이 아님');
return fetch(url + (url.indexOf('?') === -1 ? '?' : '&') + '_=' + Date.now(), {
credentials: 'same-origin',
cache: 'no-store'
}).then(function (response) {
if (!response.ok) throw new Error('SVG 다운로드 실패: HTTP ' + response.status);
return response.text();
});
});
}
function uploadNormalizedSvg(api, title, svgText, comment) {
var filename = svgFileUploadName(title);
var blob = new Blob([svgText], { type: 'image/svg+xml;charset=utf-8' });
return api.getToken('csrf').then(function (token) {
var form = new FormData();
form.append('action', 'upload');
form.append('filename', filename);
form.append('file', blob, filename);
form.append('ignorewarnings', '1');
form.append('comment', comment || 'Normalize SVG viewBox for CLBI flag thumbnail');
form.append('token', token);
form.append('format', 'json');
form.append('formatversion', '2');
return fetch(mw.util.wikiScript('api'), {
method: 'POST',
credentials: 'same-origin',
body: form
}).then(function (response) {
if (!response.ok) throw new Error('업로드 실패: HTTP ' + response.status);
return response.json();
}).then(function (data) {
if (data && data.error) throw new Error(data.error.info || data.error.code || '업로드 API 오류');
if (!data || !data.upload || (data.upload.result !== 'Success' && data.upload.result !== 'Warning')) {
throw new Error('업로드 결과 확인 실패');
}
return data;
});
});
}
function normalizeOneSvgFile(api, title) {
return fetchSvgFileText(api, title).then(function (text) {
return cropSvgTextToRenderedPixels(text).then(function (normalized) {
return uploadNormalizedSvg(api, title, normalized.text, 'CLBI SVG flag viewBox normalization').then(function () {
return {
title: normalizeSvgFileTitle(title),
ok: true,
message: normalized.oldViewBox + ' → ' + normalized.newViewBox
};
});
});
});
}
function runSvgNormalizeUpload() {
var titles = parseSvgTitleList($('#dev-tools-svg-normalize-titles').val());
var results = [];
if (!titles.length) {
setStatus('정규화할 SVG 파일명을 입력하세요.', 'error');
return;
}
if (!window.confirm('SVG 파일 ' + titles.length + '개를 실제 보이는 픽셀 범위 기준으로 정규화한 뒤 같은 파일명으로 재업로드합니다. 계속할까요?')) {
return;
}
setButtonsDisabled(true);
setStatus('SVG 정규화 준비 중...');
renderSvgNormalizeLog([]);
withApi(function (api) {
mapLimit(titles, 1, function (title, index) {
setStatus('SVG 정규화 중... ' + (index + 1) + '/' + titles.length + ' ' + title);
return normalizeOneSvgFile(api, title).then(function (result) {
results.push(result);
renderSvgNormalizeLog(results);
}).catch(function (err) {
results.push({
title: normalizeSvgFileTitle(title),
ok: false,
message: err && err.message ? err.message : String(err)
});
renderSvgNormalizeLog(results);
});
}).then(function () {
var ok = results.filter(function (item) { return item.ok; }).length;
var failed = results.length - ok;
setStatus('SVG 정규화 완료: 성공 ' + ok + '개 / 실패 ' + failed + '개', failed ? 'error' : 'ok');
}).finally(function () {
setButtonsDisabled(false);
});
}, 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-section-collapsible .dev-tools-section-toggle', function () {
var $section = $(this).closest('.dev-tools-section-collapsible');
var sectionKey = String($section.data('devtools-section') || '').trim();
var willCollapse = !$section.hasClass('is-collapsed');
$section.toggleClass('is-collapsed', willCollapse);
$(this).attr('aria-expanded', willCollapse ? 'false' : 'true');
$(this).find('.dev-tools-section-caret').text(willCollapse ? '+' : '−');
if (sectionKey) {
localStorage.setItem('dev-tools-section-' + sectionKey + '-collapsed', willCollapse ? 'true' : 'false');
}
});
$(document).on('click', '#dev-tools-export', exportSources);
$(document).on('change', '#dev-tools-package-file', analyzeSelectedPackage);
$(document).on('click', '#dev-tools-package-analyze', analyzeSelectedPackage);
$(document).on('click', '#dev-tools-package-install', installPackagePlan);
$(document).on('click', '#dev-tools-svg-current', fillCurrentSvgFileTitle);
$(document).on('click', '#dev-tools-svg-recent', fillRecentFlagSvgTitles);
$(document).on('click', '#dev-tools-svg-normalize', runSvgNormalizeUpload);
$(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-decoration-edit-toggle', toggleDecorationEditMode);
$(document).on('click', '#dev-tools-decoration-save', saveDecorationEditor);
$(document).on('click', '#dev-tools-decoration-cancel', cancelDecorationLayerEditor);
$(document).on('click', '#dev-tools-decoration-add', addDecorationLayer);
$(document).on('click', '#dev-tools-decoration-delete', deleteDecorationLayer);
$(document).on('click', '#dev-tools-decoration-grid-toggle', function () {
decoSetGridEnabled(!decoIsGridEnabled());
});
$(document).on('click', '#dev-tools-decoration-target-continent', targetActiveDecorationContinent);
$(document).on('click', '#dev-tools-decoration-center', centerDecorationLayer);
$(document).on('click', '#dev-tools-decoration-fit-width', fitDecorationLayerWidth);
$(document).on('click', '.dev-tools-decoration-layer', function () {
selectDecorationLayer($(this).attr('data-decoration-id') || '');
});
$(document).on('click', '.dev-tools-decoration-work-preview', function () {
selectDecorationLayer(this.getAttribute('data-decoration-id') || '');
});
$(document).on('pointermove.devToolsDecorationLayers', updateDecorationDragMove);
$(document).on('pointerup.devToolsDecorationLayers pointercancel.devToolsDecorationLayers', stopDecorationDrag);
$(document).on('keydown.devToolsDecorationLayers', function (event) {
var tag = event.target && event.target.tagName ? event.target.tagName.toLowerCase() : '';
var entry;
var step;
var x;
var y;
if (!decorationEditor.editMode) return;
if (tag === 'input' || tag === 'textarea' || tag === 'select') return;
if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight' && event.key !== 'ArrowUp' && event.key !== 'ArrowDown') return;
entry = getDecorationSelectedLayer();
if (!entry) return;
event.preventDefault();
step = event.shiftKey ? 10 : 1;
x = Number(entry.x) || 0;
y = Number(entry.y) || 0;
if (event.key === 'ArrowLeft') x -= step;
if (event.key === 'ArrowRight') x += step;
if (event.key === 'ArrowUp') y -= step;
if (event.key === 'ArrowDown') y += step;
setDecorationLayerPosition(x, y);
});
$(document).on('click', '.clbi-nations-tabpanel-tab[data-continent]', function () {
window.setTimeout(refreshDecorationContinentReadout, 0);
});
$(document).on('input change', '#dev-tools-decoration-label, #dev-tools-decoration-src, #dev-tools-decoration-target, #dev-tools-decoration-era, #dev-tools-decoration-continent, #dev-tools-decoration-x, #dev-tools-decoration-y, #dev-tools-decoration-width, #dev-tools-decoration-height, #dev-tools-decoration-opacity, #dev-tools-decoration-z', updateDecorationLayerFromForm);
$(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();
}
writeDecorationDefaults();
if (mw.hook) {
mw.hook('wikipage.content').add(refreshDocumentTools);
}
});
})(mediaWiki, jQuery);