미디어위키:AnecdoteViewer.js

Nxdsxn (토론 | 기여)님의 2026년 5월 30일 (토) 22:01 판

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

  • 파이어폭스 / 사파리: Shift 키를 누르면서 새로 고침을 클릭하거나, Ctrl-F5 또는 Ctrl-R을 입력 (Mac에서는 ⌘-R)
  • 구글 크롬: Ctrl-Shift-R키를 입력 (Mac에서는 ⌘-Shift-R)
  • 인터넷 익스플로러 / 엣지: Ctrl 키를 누르면서 새로 고침을 클릭하거나, Ctrl-F5를 입력.
  • 오페라: Ctrl-F5를 입력.
/* =========================================
COASTLINE: BLACK ICE - AnecdoteViewer
Anecdote namespace reader module
========================================= */

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

    if (window.AnecdoteViewerInitialized) return;
    window.AnecdoteViewerInitialized = true;

    var ROOT_SELECTOR = '.anecdote-viewer-root';
    var BODY_CLASS = 'anecdote-reader-mode';
    var HTML_CLASS = 'anecdote-reader-mode-html';
    var DECK_ID = 'anecdote-control-deck';
    var DEFAULT_LANG = 'ko';
    var initializedRoots = new WeakSet();
    var activeViewer = null;
    var booted = false;

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

    function escapeAttr(value) {
        return escapeHtml(value).replace(/`/g, '&#096;');
    }

    function normalizeSegment(value) {
        return String(value || '')
            .replace(/\s+/g, '')
            .replace(/[^A-Za-z0-9_.-]/g, '');
    }

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

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

        if (!pageName && window.location && window.location.pathname) {
            try {
                pageName = decodeURIComponent(window.location.pathname.replace(/^\/index\.php\//, ''));
            } catch (err) {
                pageName = window.location.pathname.replace(/^\/index\.php\//, '');
            }
        }

        pageName = pageName.replace(/_/g, ' ');
        return pageName.indexOf('Anecdote:') === 0 || pageName.indexOf('에넥도트:') === 0;
    }

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

    function fetchJson(title) {
        return fetch(rawUrl(title, 'application/json'), { credentials: 'same-origin' }).then(function (res) {
            if (!res.ok) throw new Error(title + ' HTTP ' + res.status);
            return res.text();
        }).then(function (text) {
            if (!text || !text.trim()) return {};
            try {
                return JSON.parse(text);
            } catch (err) {
                err.message = title + ' JSON parse failed: ' + err.message;
                throw err;
            }
        });
    }

    function buildDataTitle(meta) {
        if (meta.dataTitle) return meta.dataTitle;
        return 'MediaWiki:AnecdoteData.' + normalizeSegment(meta.work) + '.' + normalizeSegment(meta.episode) + '.' + normalizeSegment(meta.lang) + '.json';
    }

    function buildIndexTitle(meta) {
        if (meta.indexTitle) return meta.indexTitle;
        return 'MediaWiki:AnecdoteData.' + normalizeSegment(meta.work) + '.index.json';
    }

    function deriveFromPage() {
        var title = normalizePageName(mw.config.get('wgTitle') || '');
        var parts = title.split('/');
        var workLabel = parts[0] || '';
        var episodeLabel = parts.slice(1).join('/') || '';

        return {
            work: normalizeSegment(workLabel),
            episode: normalizeSegment(episodeLabel),
            workLabel: workLabel,
            episodeLabel: episodeLabel
        };
    }

    function readRootConfig(root) {
        var derived = deriveFromPage();
        var work = root.getAttribute('data-work') || derived.work;
        var episode = root.getAttribute('data-episode') || derived.episode;
        var lang = root.getAttribute('data-lang') || (typeof window.getCurrentLang === 'function' ? window.getCurrentLang() : DEFAULT_LANG);

        return {
            work: work,
            episode: episode,
            lang: lang || DEFAULT_LANG,
            workLabel: root.getAttribute('data-work-title') || derived.workLabel || work,
            episodeLabel: root.getAttribute('data-episode-title') || derived.episodeLabel || episode,
            indexTitle: root.getAttribute('data-index-title') || root.getAttribute('data-index') || '',
            dataTitle: root.getAttribute('data-data-title') || root.getAttribute('data-source-title') || root.getAttribute('data-source') || '',
            returnTitle: root.getAttribute('data-return-title') || '',
            returnHref: root.getAttribute('data-return') || '',
            autoRoot: root.getAttribute('data-auto-root') === 'true'
        };
    }

    function ensureNamespaceRootIfNeeded() {
        if (!isAnecdoteNamespace()) return [];

        var existing = Array.prototype.slice.call(document.querySelectorAll(ROOT_SELECTOR));
        if (existing.length) return existing;

        var parser = document.querySelector('.liberty-content-main .mw-parser-output') || document.querySelector('.mw-parser-output');
        if (!parser) return [];

        var root = document.createElement('div');
        root.className = ROOT_SELECTOR.slice(1);
        root.setAttribute('data-auto-root', 'true');
        parser.insertBefore(root, parser.firstChild || null);
        return [root];
    }

    function setReaderMode(config) {
        document.documentElement.classList.add(HTML_CLASS);
        document.body.classList.add(BODY_CLASS);
        document.body.setAttribute('data-anecdote-work', config && config.work ? config.work : '');
        document.body.setAttribute('data-anecdote-episode', config && config.episode ? config.episode : '');
        document.body.setAttribute('data-anecdote-lang', config && config.lang ? config.lang : DEFAULT_LANG);
    }

    function destroyActiveViewer() {
        if (activeViewer && typeof activeViewer.destroy === 'function') {
            activeViewer.destroy();
        }
        activeViewer = null;
    }

    function removeDeck() {
        var deck = document.getElementById(DECK_ID);
        if (deck && deck.parentNode) {
            deck.parentNode.removeChild(deck);
        }
    }

    function clearReaderMode() {
        destroyActiveViewer();
        document.documentElement.classList.remove(HTML_CLASS);
        document.body.classList.remove(BODY_CLASS);
        document.body.removeAttribute('data-anecdote-work');
        document.body.removeAttribute('data-anecdote-episode');
        document.body.removeAttribute('data-anecdote-lang');
        removeDeck();
        booted = false;
    }

    function renderSystem(root, config, type, title, text, code) {
        setReaderMode(config || {});
        root.innerHTML = '' +
            '<section class="anecdote-viewer is-' + escapeAttr(type || 'error') + '">' +
                '<div class="anecdote-system-panel">' +
                    '<div class="anecdote-system-title">' + escapeHtml(title || 'ANECDOTE VIEWER') + '</div>' +
                    '<div class="anecdote-system-text">' + escapeHtml(text || '') + '</div>' +
                    (code ? '<pre class="anecdote-system-code">' + escapeHtml(code) + '</pre>' : '') +
                '</div>' +
            '</section>';
    }

    function getEntries(data) {
        if (!data || typeof data !== 'object') return [];
        return data.entries || data.scenes || data.lines || [];
    }

    function getCharacters(data) {
        return data && data.characters && typeof data.characters === 'object' ? data.characters : {};
    }

    function getCharacter(data, id) {
        var characters = getCharacters(data);
        var key = String(id || '');
        return characters[key] || characters[key.toLowerCase()] || null;
    }

    function getEntryType(entry) {
        return String(entry && entry.type ? entry.type : 'narration').toLowerCase();
    }

    function getEntrySpeaker(data, entry) {
        if (!entry) return '';
        var id = entry.character || entry.speaker || entry.characterId || '';
        var character = getCharacter(data, id);
        return entry.name || (character && character.name) || id || '';
    }

    function getEntryPortrait(data, entry) {
        if (!entry) return '';
        var id = entry.character || entry.speaker || entry.characterId || '';
        var character = getCharacter(data, id);
        return entry.portrait || (character && character.portrait) || '';
    }

    function getEntryAvatar(data, entry) {
        if (!entry) return '';
        var id = entry.character || entry.speaker || entry.characterId || '';
        var character = getCharacter(data, id);
        return entry.avatar || (character && character.avatar) || getEntryPortrait(data, entry) || '';
    }

    function formatText(value) {
        if (value == null) return '';
        return escapeHtml(value)
            .replace(/\r\n/g, '\n')
            .replace(/\r/g, '\n')
            .split(/\n{2,}/)
            .map(function (paragraph) {
                return '<p>' + paragraph.replace(/\n/g, '<br>') + '</p>';
            })
            .join('');
    }

    function getEntryHtml(data, entry) {
        if (!entry) return '';
        if (entry.html != null) return String(entry.html);

        var type = getEntryType(entry);
        var text = entry.text != null ? entry.text : '';

        if (type === 'chat') {
            var avatar = getEntryAvatar(data, entry);
            return '' +
                '<div class="anecdote-chat-card">' +
                    (avatar ? '<img class="anecdote-chat-avatar" src="' + escapeAttr(avatar) + '" alt="">' : '<div class="anecdote-chat-avatar" aria-hidden="true"></div>') +
                    '<div class="anecdote-chat-bubble">' + formatText(text) + '</div>' +
                '</div>';
        }

        return formatText(text);
    }

    function getBackground(data, entry) {
        if (entry && entry.background) return entry.background;
        if (data && data.assets && data.assets.background) return data.assets.background;
        if (data && data.background) return data.background;
        return '';
    }

    function findEpisodeIndex(indexData, episodeId) {
        var episodes = indexData && Array.isArray(indexData.episodes) ? indexData.episodes : [];
        var id = String(episodeId || '');
        for (var i = 0; i < episodes.length; i += 1) {
            if (String(episodes[i].id || '') === id) return i;
        }
        return -1;
    }

    function buildEpisodeHref(episode) {
        if (!episode) return '';
        if (episode.href) return episode.href;
        if (episode.pageTitle) return mw.util.getUrl(episode.pageTitle);
        if (episode.page) return mw.util.getUrl(episode.page);
        return '';
    }

    function getEpisodeNav(indexData, episodeId) {
        var episodes = indexData && Array.isArray(indexData.episodes) ? indexData.episodes : [];
        var index = findEpisodeIndex(indexData, episodeId);
        if (index < 0) return { previous: null, current: null, next: null };
        return {
            previous: episodes[index - 1] || null,
            current: episodes[index] || null,
            next: episodes[index + 1] || null
        };
    }

    function progressKey(config) {
        return 'anecdote.progress.' + normalizeSegment(config.work) + '.' + normalizeSegment(config.episode) + '.' + normalizeSegment(config.lang);
    }

    function getSavedIndex(config, max) {
        var raw = localStorage.getItem(progressKey(config));
        var value = parseInt(raw || '0', 10);
        if (!isFinite(value) || value < 0) return 0;
        return Math.min(value, Math.max(0, max - 1));
    }

    function saveIndex(config, index) {
        try {
            localStorage.setItem(progressKey(config), String(Math.max(0, index || 0)));
        } catch (err) {}
    }

    function buildReturnHref(config) {
        if (config.returnHref) return config.returnHref;
        if (config.returnTitle) return mw.util.getUrl(config.returnTitle);
        return mw.util.getUrl('대문');
    }

    function makeShell(root, config, indexData, episodeData) {
        var entries = getEntries(episodeData);
        var workTitle = episodeData.workTitle || indexData.title || config.workLabel || config.work;
        var episodeTitle = episodeData.title || config.episodeLabel || config.episode;
        var total = entries.length;

        root.innerHTML = '' +
            '<section class="anecdote-viewer is-ready" data-work="' + escapeAttr(config.work) + '" data-episode="' + escapeAttr(config.episode) + '">' +
                '<div class="anecdote-frame">' +
                    '<div class="anecdote-titlebar">' +
                        '<div class="anecdote-title-main">' +
                            '<span class="anecdote-title-label">ANECDOTE</span>' +
                            '<span class="anecdote-title-text">' + escapeHtml(workTitle) + ' / ' + escapeHtml(episodeTitle) + '</span>' +
                        '</div>' +
                        '<div class="anecdote-title-meta">' +
                            '<span class="anecdote-counter"><span data-anecdote-current>000</span>/<span data-anecdote-total>' + String(total).padStart(3, '0') + '</span></span>' +
                            '<span class="anecdote-progress-meter" aria-hidden="true"><span class="anecdote-progress-fill" data-anecdote-progress></span></span>' +
                        '</div>' +
                    '</div>' +
                    '<div class="anecdote-stage">' +
                        '<div class="anecdote-backdrop"><div class="anecdote-backdrop-image" data-anecdote-backdrop></div></div>' +
                        '<div class="anecdote-stage-grid">' +
                            '<div class="anecdote-portrait-bay" data-anecdote-portrait-bay><div class="anecdote-portrait-empty" aria-hidden="true">?</div></div>' +
                            '<div class="anecdote-display">' +
                                '<article class="anecdote-entry-card is-narration" data-anecdote-entry>' +
                                    '<div class="anecdote-speaker-row" data-anecdote-speaker-row>' +
                                        '<span class="anecdote-speaker-kicker" data-anecdote-speaker-kicker>TEXT</span>' +
                                        '<span class="anecdote-speaker-name" data-anecdote-speaker></span>' +
                                    '</div>' +
                                    '<div class="anecdote-entry-body" data-anecdote-body></div>' +
                                    '<div class="anecdote-entry-footer">' +
                                        '<span class="anecdote-entry-hint">SPACE / CLICK TO ADVANCE</span>' +
                                        '<span class="anecdote-entry-count" data-anecdote-entry-count></span>' +
                                    '</div>' +
                                '</article>' +
                                '<div class="anecdote-inline-controls">' +
                                    '<button type="button" class="anecdote-button" data-anecdote-action="prev">PREV</button>' +
                                    '<button type="button" class="anecdote-button" data-anecdote-action="log">LOG</button>' +
                                    '<button type="button" class="anecdote-button" data-anecdote-action="next">NEXT</button>' +
                                '</div>' +
                            '</div>' +
                        '</div>' +
                        '<div class="anecdote-log-panel" data-anecdote-log-panel>' +
                            '<div class="anecdote-log-titlebar"><span>READING LOG</span><button type="button" class="anecdote-button" data-anecdote-action="log-close">CLOSE</button></div>' +
                            '<div class="anecdote-log-list" data-anecdote-log-list></div>' +
                        '</div>' +
                    '</div>' +
                '</div>' +
            '</section>';

        return root.querySelector('.anecdote-viewer');
    }

    function updateDeck(viewer) {
        var deck = document.getElementById(DECK_ID);
        if (!deck) return;

        if (!viewer) {
            deck.innerHTML = '';
            return;
        }

        var nav = getEpisodeNav(viewer.indexData, viewer.config.episode);
        var previousHref = buildEpisodeHref(nav.previous);
        var nextHref = buildEpisodeHref(nav.next);
        var returnHref = buildReturnHref(viewer.config);

        deck.innerHTML = '' +
            '<div class="anecdote-control-frame">' +
                '<div class="anecdote-control-brand">ANECDOTE</div>' +
                '<a class="anecdote-link-button" href="' + escapeAttr(returnHref) + '">WIKI</a>' +
                '<a class="anecdote-link-button ' + (previousHref ? '' : 'is-disabled') + '" href="' + escapeAttr(previousHref || '#') + '">PREV EP</a>' +
                '<button type="button" class="anecdote-button" data-deck-action="log">LOG</button>' +
                '<button type="button" class="anecdote-button" data-deck-action="auto">AUTO</button>' +
                '<a class="anecdote-link-button ' + (nextHref ? '' : 'is-disabled') + '" href="' + escapeAttr(nextHref || '#') + '">NEXT EP</a>' +
                '<button type="button" class="anecdote-button" data-deck-action="next">NEXT</button>' +
            '</div>';

        var logBtn = deck.querySelector('[data-deck-action="log"]');
        var autoBtn = deck.querySelector('[data-deck-action="auto"]');
        var nextBtn = deck.querySelector('[data-deck-action="next"]');

        if (logBtn) logBtn.addEventListener('click', function () { viewer.toggleLog(); });
        if (autoBtn) autoBtn.addEventListener('click', function () { viewer.toggleAuto(); });
        if (nextBtn) nextBtn.addEventListener('click', function () { viewer.next(); });
    }

    function ensureDeck() {
        var deck = document.getElementById(DECK_ID);
        if (deck) return deck;
        deck = document.createElement('div');
        deck.id = DECK_ID;
        document.body.appendChild(deck);
        return deck;
    }

    function createViewerController(root, config, indexData, episodeData) {
        var entries = getEntries(episodeData);
        var viewerEl = makeShell(root, config, indexData, episodeData);
        var state = {
            root: root,
            el: viewerEl,
            config: config,
            indexData: indexData || {},
            episodeData: episodeData || {},
            entries: entries,
            index: getSavedIndex(config, entries.length),
            autoTimer: null,
            next: next,
            previous: previous,
            toggleLog: toggleLog,
            toggleAuto: toggleAuto,
            destroy: destroy,
            render: render
        };

        function destroy() {
            if (state.autoTimer) {
                clearInterval(state.autoTimer);
                state.autoTimer = null;
            }

            if (state.el) {
                state.el.classList.remove('is-auto-playing');
                state.el.classList.remove('is-log-open');
            }
        }

        function render() {
            var entry = state.entries[state.index] || null;
            var type = getEntryType(entry);
            var speaker = getEntrySpeaker(state.episodeData, entry);
            var portrait = getEntryPortrait(state.episodeData, entry);
            var background = getBackground(state.episodeData, entry);
            var entryCard = viewerEl.querySelector('[data-anecdote-entry]');
            var speakerRow = viewerEl.querySelector('[data-anecdote-speaker-row]');
            var speakerKicker = viewerEl.querySelector('[data-anecdote-speaker-kicker]');
            var speakerName = viewerEl.querySelector('[data-anecdote-speaker]');
            var body = viewerEl.querySelector('[data-anecdote-body]');
            var current = viewerEl.querySelector('[data-anecdote-current]');
            var progress = viewerEl.querySelector('[data-anecdote-progress]');
            var count = viewerEl.querySelector('[data-anecdote-entry-count]');
            var backdrop = viewerEl.querySelector('[data-anecdote-backdrop]');
            var portraitBay = viewerEl.querySelector('[data-anecdote-portrait-bay]');
            var previousButton = viewerEl.querySelector('[data-anecdote-action="prev"]');
            var nextButton = viewerEl.querySelector('[data-anecdote-action="next"]');
            var logButton = viewerEl.querySelector('[data-anecdote-action="log"]');
            var deckAuto = document.querySelector('#' + DECK_ID + ' [data-deck-action="auto"]');

            if (entryCard) {
                entryCard.className = 'anecdote-entry-card is-' + escapeAttr(type || 'narration');
            }

            if (speakerRow) {
                speakerRow.style.display = (type === 'narration' && !speaker) ? 'none' : 'flex';
            }

            if (speakerKicker) {
                speakerKicker.textContent = type === 'dialogue' ? 'SPEAKER' : type.toUpperCase();
            }

            if (speakerName) {
                speakerName.textContent = speaker || (type === 'narration' ? '' : 'SYSTEM');
            }

            if (body) {
                body.innerHTML = entry ? getEntryHtml(state.episodeData, entry) : '<p>표시할 엔트리가 없습니다.</p>';
            }

            if (current) current.textContent = String(Math.min(state.index + 1, state.entries.length)).padStart(3, '0');
            if (progress) progress.style.width = state.entries.length ? (((state.index + 1) / state.entries.length) * 100).toFixed(3) + '%' : '0%';
            if (count) count.textContent = state.entries.length ? (String(state.index + 1) + ' / ' + String(state.entries.length)) : '0 / 0';

            if (backdrop) {
                backdrop.style.backgroundImage = background ? 'url("' + String(background).replace(/"/g, '\\"') + '")' : 'none';
            }

            if (portraitBay) {
                portraitBay.innerHTML = portrait ? '<img class="anecdote-portrait-image" src="' + escapeAttr(portrait) + '" alt="">' : '<div class="anecdote-portrait-empty" aria-hidden="true">?</div>';
            }

            if (previousButton) previousButton.disabled = state.index <= 0;
            if (nextButton) nextButton.disabled = state.entries.length === 0 || state.index >= state.entries.length - 1;
            if (logButton) logButton.classList.toggle('is-active', viewerEl.classList.contains('is-log-open'));
            if (deckAuto) deckAuto.classList.toggle('is-active', !!state.autoTimer);

            renderLog();
            saveIndex(state.config, state.index);
        }

        function renderLog() {
            var logList = viewerEl.querySelector('[data-anecdote-log-list]');
            if (!logList) return;

            var html = [];
            var end = Math.min(state.index, state.entries.length - 1);
            for (var i = 0; i <= end; i += 1) {
                var entry = state.entries[i];
                var speaker = getEntrySpeaker(state.episodeData, entry);
                var type = getEntryType(entry);
                var plain = entry && entry.text != null ? entry.text : '';
                if (!plain && entry && entry.html != null) {
                    plain = String(entry.html).replace(/<[^>]*>/g, '');
                }
                html.push(
                    '<div class="anecdote-log-item">' +
                        '<span class="anecdote-log-speaker">' + escapeHtml(speaker || type.toUpperCase()) + '</span>' +
                        '<span>' + formatText(plain) + '</span>' +
                    '</div>'
                );
            }
            logList.innerHTML = html.join('') || '<div class="anecdote-log-item"><span>기록이 없습니다.</span></div>';
            logList.scrollTop = logList.scrollHeight;
        }

        function next() {
            if (state.index < state.entries.length - 1) {
                state.index += 1;
                render();
            } else {
                stopAuto();
            }
        }

        function previous() {
            if (state.index > 0) {
                state.index -= 1;
                render();
            }
        }

        function toggleLog() {
            viewerEl.classList.toggle('is-log-open');
            render();
        }

        function stopAuto() {
            if (state.autoTimer) {
                clearInterval(state.autoTimer);
                state.autoTimer = null;
                render();
            }
        }

        function toggleAuto() {
            if (state.autoTimer) {
                stopAuto();
                return;
            }
            state.autoTimer = setInterval(function () {
                if (document.hidden) return;
                next();
            }, parseInt(episodeData.autoInterval || 2600, 10));
            render();
        }

        viewerEl.addEventListener('click', function (event) {
            var actionEl = event.target.closest('[data-anecdote-action]');
            if (!actionEl) {
                if (event.target.closest('.anecdote-entry-card')) next();
                return;
            }

            var action = actionEl.getAttribute('data-anecdote-action');
            if (action === 'next') next();
            if (action === 'prev') previous();
            if (action === 'log' || action === 'log-close') toggleLog();
        });

        render();
        return state;
    }

    function bindKeyboard() {
        if (window.AnecdoteViewerKeyboardBound) return;
        window.AnecdoteViewerKeyboardBound = true;

        document.addEventListener('keydown', function (event) {
            if (!document.body.classList.contains(BODY_CLASS) || !activeViewer) return;
            if (event.target && /input|textarea|select/i.test(event.target.tagName)) return;

            if (event.key === ' ' || event.key === 'ArrowRight') {
                event.preventDefault();
                activeViewer.next();
            }

            if (event.key === 'ArrowLeft') {
                event.preventDefault();
                activeViewer.previous();
            }

            if (event.key && event.key.toLowerCase() === 'l') {
                activeViewer.toggleLog();
            }
        });
    }

    function initRoot(root) {
        if (!root || initializedRoots.has(root)) return;

        if (activeViewer && activeViewer.root && activeViewer.root !== root) {
            destroyActiveViewer();
        }

        initializedRoots.add(root);

        var config = readRootConfig(root);
        setReaderMode(config);
        ensureDeck();

        if (!config.work || !config.episode) {
            renderSystem(
                root,
                config,
                'config-error',
                'ANECDOTE CONFIG REQUIRED',
                'AnecdoteViewer 마운트에 work와 episode가 필요합니다.',
                '{{AnecdoteViewer\n|work=CrimsonLeather\n|episode=prologue\n|lang=ko\n}}'
            );
            updateDeck(null);
            return;
        }

        activeViewer = null;
        renderSystem(root, config, 'loading', 'ANECDOTE VIEWER', '데이터를 불러오는 중입니다.', buildDataTitle(config));

        Promise.all([
            fetchJson(buildIndexTitle(config)).catch(function (err) {
                console.warn('Anecdote index load failed:', err);
                return { title: config.workLabel || config.work, episodes: [] };
            }),
            fetchJson(buildDataTitle(config))
        ]).then(function (result) {
            var indexData = result[0] || {};
            var episodeData = result[1] || {};
            var entries = getEntries(episodeData);

            if (!entries.length) {
                renderSystem(
                    root,
                    config,
                    'error',
                    'ANECDOTE DATA EMPTY',
                    '데이터 파일을 읽었지만 entries 배열이 비어 있습니다.',
                    buildDataTitle(config)
                );
                updateDeck(null);
                return;
            }

            activeViewer = createViewerController(root, config, indexData, episodeData);
            updateDeck(activeViewer);
            bindKeyboard();
        }).catch(function (err) {
            renderSystem(
                root,
                config,
                'error',
                'ANECDOTE DATA LOAD FAILED',
                String(err && err.message ? err.message : err),
                buildDataTitle(config)
            );
            updateDeck(null);
        });
    }

    function boot() {
        var roots = ensureNamespaceRootIfNeeded();
        var found = Array.prototype.slice.call(document.querySelectorAll(ROOT_SELECTOR));
        roots.forEach(function (root) {
            if (found.indexOf(root) === -1) found.push(root);
        });

        if (!found.length) {
            clearReaderMode();
            return;
        }

        if (activeViewer && activeViewer.root && found.indexOf(activeViewer.root) === -1) {
            destroyActiveViewer();
            removeDeck();
        }

        booted = true;
        found.forEach(initRoot);
    }

    $(function () {
        boot();
    });

    mw.hook('wikipage.content').add(function () {
        boot();
    });

    window.AnecdoteViewer = {
        boot: boot,
        isAnecdoteNamespace: isAnecdoteNamespace,
        getActive: function () { return activeViewer; },
        isBooted: function () { return booted; }
    };
})(mediaWiki, jQuery);