Открыть меню
Переключить меню настроек
Открыть персональное меню
Вы не представились системе
Ваш IP-адрес будет виден всем, если вы внесёте какие-либо изменения.

MediaWiki:ScmcScanner.js: различия между версиями

Страница интерфейса MediaWiki
Новая страница: «(function () { if (!document.querySelector('.scmc-link-scanner')) return; var STATUS = { waiting: 'Ожидает запуска', scanning: 'Сканирование...', done: 'Готово', error: 'Ошибка' }; var EXCLUDED_PREFIXES = [ 'Файл:', 'File:', 'Категория:', 'Category:', 'Шаблон:', 'Template:', 'Участник:',...»
 
Нет описания правки
 
(не показано 6 промежуточных версий этой же участницы)
Строка 1: Строка 1:
(function () {
(function () {
     if (!document.querySelector('.scmc-link-scanner')) return;
     var scannerRoot = document.querySelector('.scmc-link-scanner');
 
     if (!scannerRoot) return;
     var STATUS = {
        waiting: 'Ожидает запуска',
        scanning: 'Сканирование...',
        done: 'Готово',
        error: 'Ошибка'
    };


     var EXCLUDED_PREFIXES = [
     var EXCLUDED_PREFIXES = [
Строка 32: Строка 26:
         return String(title || '')
         return String(title || '')
             .replace(/_/g, ' ')
             .replace(/_/g, ' ')
            .replace(/\s+/g, ' ')
             .trim();
             .trim();
     }
     }


     function titleToLink(title) {
     function titleKey(title) {
        return normalizeTitle(title).toLowerCase();
    }
 
    function titleToUrl(title) {
         return mw.util.getUrl(title);
         return mw.util.getUrl(title);
     }
     }


    function isExcludedTitle(title) {
function titleToEditUrl(title) {
        var normalized = normalizeTitle(title);
return mw.util.getUrl(title, { action: 'edit' });
}


        if (!normalized) return true;
function makePageLink(title, text, className) {
        if (normalized.indexOf('#') !== -1) normalized = normalized.split('#')[0];
var link = document.createElement('a');
link.href = titleToUrl(title);
link.textContent = text || title;


        return EXCLUDED_PREFIXES.some(function (prefix) {
  if (className) {
            return normalized.indexOf(prefix) === 0;
  link.className = className;
         });
  }
 
  return link;
}
 
function makeEditLink(title, text, className) {
    var link = document.createElement('a');
    link.href = titleToEditUrl(title);
    link.textContent = text || 'код';
 
    if (className) {
         link.className = className;
     }
     }


     function createEl(tag, className, text) {
    link.target = '_blank';
    link.rel = 'noopener';
 
    return link;
}
 
     function makeEl(tag, className, text) {
         var el = document.createElement(tag);
         var el = document.createElement(tag);
         if (className) el.className = className;
         if (className) el.className = className;
Строка 57: Строка 76:
     }
     }


     function createButton(text, className) {
     function makeButton(text, className) {
         var button = document.createElement('button');
         var btn = document.createElement('button');
         button.type = 'button';
         btn.type = 'button';
         button.className = className || 'scmc-scan-btn';
         btn.className = className || 'scmc-scan-btn';
         button.textContent = text;
         btn.textContent = text;
         return button;
         return btn;
    }
 
    function splitDataList(value) {
        return String(value || '')
            .split('|')
            .map(normalizeTitle)
            .filter(Boolean);
    }
 
    function uniqueSorted(list) {
        var seen = {};
 
        list.forEach(function (item) {
            var title = normalizeTitle(item);
            if (title) seen[titleKey(title)] = title;
        });
 
        return Object.keys(seen)
            .map(function (key) {
                return seen[key];
            })
            .sort(function (a, b) {
                return a.localeCompare(b, 'ru');
            });
    }
 
    function chunkArray(items, size) {
        var chunks = [];
 
        for (var i = 0; i < items.length; i += size) {
            chunks.push(items.slice(i, i + size));
        }
 
        return chunks;
    }
 
    function extractAttr(tag, names) {
        for (var i = 0; i < names.length; i++) {
            var name = names[i];
            var re = new RegExp(name + '\\s*=\\s*(["\\\'])(.*?)\\1', 'i');
            var match = tag.match(re);
 
            if (match) {
                return match[2];
            }
        }
 
        return '';
    }
 
    function isStopCatalogRow(tag) {
        var scanValue = normalizeTitle(extractAttr(tag, ['data-scan', 'data_scan'])).toLowerCase();
        var classValue = ' ' + normalizeTitle(extractAttr(tag, ['class'])).toLowerCase() + ' ';
 
        return scanValue === 'stop' ||
            classValue.indexOf(' scmc-scan-stop ') !== -1 ||
            classValue.indexOf(' scan-stop ') !== -1 ||
            classValue.indexOf(' stop ') !== -1;
    }
 
 
    function encodeAttr(value) {
        return String(value || '')
            .replace(/&/g, '&amp;')
            .replace(/"/g, '&quot;')
            .replace(/</g, '&lt;')
            .replace(/>/g, '&gt;');
    }
 
    function getLevelNumberFromClass(className) {
        var match = String(className || '').match(/\bscmc-level-([1-5])\b/);
        return match ? parseInt(match[1], 10) : 1;
    }
 
    function normalizeLevel(level) {
        level = parseInt(level, 10);
        if (!level || level < 1) return 1;
        if (level > 5) return 5;
        return level;
    }
 
    function cleanCatalogClass(level) {
        return 'scmc-catalog-row scmc-level-' + normalizeLevel(level);
    }
 
    function titleFromPage(page) {
        return normalizeTitle(page).replace(/_/g, ' ');
    }
 
    function buildCatalogRowLine(data) {
        var page = normalizeTitle(data.page);
        var title = normalizeTitle(data.title || titleFromPage(page));
        var section = normalizeTitle(data.section);
        var level = normalizeLevel(data.level);
        var status = String(data.status || '').trim();
        var scan = data.scan === 'stop' ? 'stop' : '';
        var note = normalizeTitle(data.note || '');
        var out = '<div class="' + encodeAttr(cleanCatalogClass(level)) + '"';
 
        out += ' data-section="' + encodeAttr(section) + '"';
        out += ' data-page="' + encodeAttr(page) + '"';
        out += ' data-title="' + encodeAttr(title) + '"';
 
        if (scan) {
            out += ' data-scan="stop"';
        }
 
        out += ' data-status="' + encodeAttr(status) + '"';
 
        if (note) {
            out += ' data-note="' + encodeAttr(note) + '"';
        }
 
        if (/^https?:\/\//i.test(page)) {
            out += '>[' + page + ' ' + title + ']</div>';
        } else {
            out += '>[[' + page + '|' + title + ']]</div>';
        }
 
        return out;
     }
     }


Строка 69: Строка 208:
     }
     }


     function uniqueSorted(items) {
     function resolveTitles(api, titles) {
         var map = {};
        var unique = uniqueSorted(titles);
         items.forEach(function (item) {
        var chunks = chunkArray(unique, 45);
            map[normalizeTitle(item)] = true;
        var finalResult = {};
         var allRedirects = {};
 
         function followRedirect(title, redirectMap) {
            var current = normalizeTitle(title);
            var guard = 0;
 
            while (redirectMap[titleKey(current)] && guard < 10) {
                current = redirectMap[titleKey(current)];
                guard++;
            }
 
            return current;
        }
 
        function processChunk(chunk) {
            if (!chunk.length) return $.Deferred().resolve().promise();
 
            return api.get({
                action: 'query',
                titles: chunk.join('|'),
                redirects: 1,
                formatversion: 2
            }).then(function (data) {
                var redirectMap = {};
                var pagesByKey = {};
 
                if (data.query && data.query.redirects) {
                    data.query.redirects.forEach(function (redirect) {
                        var from = normalizeTitle(redirect.from);
                        var to = normalizeTitle(redirect.to);
 
                        redirectMap[titleKey(from)] = to;
                        allRedirects[from] = to;
                    });
                }
 
                if (data.query && data.query.pages) {
                    data.query.pages.forEach(function (page) {
                        var pageTitle = normalizeTitle(page.title);
 
                        pagesByKey[titleKey(pageTitle)] = {
                            title: pageTitle,
                            exists: page.missing === undefined
                        };
                    });
                }
 
                chunk.forEach(function (requested) {
                    var requestedTitle = normalizeTitle(requested);
                    var finalTitle = followRedirect(requestedTitle, redirectMap);
                    var page = pagesByKey[titleKey(finalTitle)];
 
                    finalResult[titleKey(requestedTitle)] = {
                        requestedTitle: requestedTitle,
                        finalTitle: page ? page.title : finalTitle,
                        exists: page ? page.exists : null
                    };
                });
            });
        }
 
        var chain = $.Deferred().resolve().promise();
 
        chunks.forEach(function (chunk) {
            chain = chain.then(function () {
                return processChunk(chunk);
            });
         });
         });
         return Object.keys(map).sort(function (a, b) {
 
             return a.localeCompare(b, 'ru');
         return chain.then(function () {
             return {
                items: finalResult,
                redirects: allRedirects
            };
         });
         });
     }
     }


     function shouldFollowPage(page) {
     function loadCatalog(api, catalogTitle) {
        if (!page || page.missing !== undefined) return false;
        return api.get({
        if (typeof page.ns === 'number' && page.ns !== 0) return false;
            action: 'parse',
        if (isExcludedTitle(page.title)) return false;
            page: catalogTitle,
         return true;
            prop: 'wikitext',
            formatversion: 2
        }).then(function (data) {
            var wikitext = '';
 
            if (data.parse && typeof data.parse.wikitext === 'string') {
                wikitext = data.parse.wikitext;
            } else if (data.parse && data.parse.wikitext && data.parse.wikitext['*']) {
                wikitext = data.parse.wikitext['*'];
            }
 
            var rawPages = [];
            var rawStopPages = {};
            var catalogRows = [];
            var rowRe = /<div\b[^>]*scmc-catalog-row[^>]*>/gi;
            var match;
 
            while ((match = rowRe.exec(wikitext)) !== null) {
                var tag = match[0];
                var className = extractAttr(tag, ['class']);
                var page = normalizeTitle(extractAttr(tag, ['data-page', 'data_page']));
                var title = normalizeTitle(extractAttr(tag, ['data-title', 'data_title'])) || page;
                var section = normalizeTitle(extractAttr(tag, ['data-section', 'data_section']));
                var level = getLevelNumberFromClass(className);
 
                if (!page) continue;
 
                rawPages.push(page);
 
                var rowData = {
                    page: page,
                    title: title,
                    section: section,
                    level: level,
                    position: catalogRows.length + 1,
                    scan: isStopCatalogRow(tag) ? 'stop' : '',
                    status: normalizeTitle(extractAttr(tag, ['data-status', 'data_status']))
                };
 
                catalogRows.push(rowData);
 
                if (rowData.scan === 'stop') {
                    rawStopPages[titleKey(page)] = true;
                }
            }
 
            return resolveTitles(api, rawPages).then(function (resolved) {
                var knownPages = {};
                var stopPages = {};
                var redirects = resolved.redirects;
                var rowsByKey = {};
                var sectionMap = {};
                var sections = [];
 
                catalogRows.forEach(function (rowData) {
                    var rawPage = rowData.page;
                    var rawKey = titleKey(rawPage);
                    var resolvedItem = resolved.items[rawKey];
                    var finalTitle = resolvedItem ? resolvedItem.finalTitle : rawPage;
                    var finalKey = titleKey(finalTitle);
                    var enriched = Object.assign({}, rowData, {
                        finalTitle: finalTitle
                    });
 
                    knownPages[rawKey] = rawPage;
                    knownPages[finalKey] = finalTitle;
                    rowsByKey[rawKey] = enriched;
                    rowsByKey[finalKey] = enriched;
 
                    if (rowData.section && !sectionMap[rowData.section]) {
                        sectionMap[rowData.section] = true;
                        sections.push(rowData.section);
                    }
 
                    if (rawStopPages[rawKey]) {
                        stopPages[rawKey] = true;
                        stopPages[finalKey] = true;
                    }
                });
 
                var preferredOrder = [
                    'Морпехи',
                    'Ксеноморфы',
                    'Правила и процедуры',
                    'Справочник',
                    'Лор и материалы',
                    'Общее'
                ];
 
                sections.sort(function (a, b) {
                    var ai = preferredOrder.indexOf(a);
                    var bi = preferredOrder.indexOf(b);
 
                    if (ai !== -1 || bi !== -1) {
                        if (ai === -1) return 1;
                        if (bi === -1) return -1;
                        return ai - bi;
                    }
 
                    return a.localeCompare(b, 'ru');
                });
 
                return {
                    knownPages: knownPages,
                    stopPages: stopPages,
                    redirects: redirects,
                    catalogRows: catalogRows,
                    rowsByKey: rowsByKey,
                    sections: sections,
                    count: Object.keys(knownPages).length
                };
            });
         });
     }
     }


     function getPageLinks(api, title) {
     function buildScanner(container) {
         var links = [];
         var rootTitle = normalizeTitle(container.getAttribute('data-root') || 'Marine_Corps');
         var normalizedTitle = normalizeTitle(title);
         var catalogTitle = normalizeTitle(container.getAttribute('data-catalog') || 'MC:Страницы');
        var maxDepth = parseInt(container.getAttribute('data-depth') || '5', 10);
        var maxPages = parseInt(container.getAttribute('data-max-pages') || '300', 10);
        var extraExcludedTitles = splitDataList(container.getAttribute('data-exclude'));
 
        var latestCatalogData = null;


         function request(plcontinue) {
         function getCatalogSource(api) {
             var params = {
             return api.get({
                 action: 'query',
                 action: 'query',
                 prop: 'links',
                 prop: 'revisions',
                 titles: normalizedTitle,
                 titles: catalogTitle,
                 pllimit: 'max',
                 rvprop: 'content',
                rvslots: 'main',
                formatversion: 2
            }).then(function (data) {
                var pageData = data.query && data.query.pages ? data.query.pages[0] : null;
                var rev = pageData && pageData.revisions ? pageData.revisions[0] : null;
 
                if (rev && rev.slots && rev.slots.main && typeof rev.slots.main.content === 'string') {
                    return rev.slots.main.content;
                }
 
                if (rev && typeof rev.content === 'string') {
                    return rev.content;
                }
 
                throw new Error('Не удалось прочитать код каталога.');
            });
        }
 
        function saveCatalogSource(api, text) {
            return api.postWithToken('csrf', {
                action: 'edit',
                title: catalogTitle,
                text: text,
                summary: 'Добавление страницы в каталог Marine Corps из сканера',
                minor: true,
                 formatversion: 2
                 formatversion: 2
            });
        }
        function getCatalogRowsFromSource(source) {
            var rows = [];
            var re = /<div\b[^>]*\bscmc-catalog-row\b[^>]*>[\s\S]*?<\/div>/gi;
            var match;
            while ((match = re.exec(source)) !== null) {
                var full = match[0];
                var open = full.match(/^<div\b[^>]*>/i);
                if (!open) continue;
                var tag = open[0];
                var page = normalizeTitle(extractAttr(tag, ['data-page', 'data_page']));
                var title = normalizeTitle(extractAttr(tag, ['data-title', 'data_title'])) || page;
                var section = normalizeTitle(extractAttr(tag, ['data-section', 'data_section']));
                var level = getLevelNumberFromClass(extractAttr(tag, ['class']));
                if (!page) continue;
                rows.push({
                    start: match.index,
                    end: match.index + full.length,
                    full: full,
                    data: {
                        page: page,
                        title: title,
                        section: section,
                        level: level,
                        position: rows.length + 1
                    }
                });
            }
            return rows;
        }
        function normalizePlaceInput(value, count) {
            var text = String(value || '').trim();
            if (!text) {
                return count;
            }
            if (!/^\d+$/.test(text)) {
                throw new Error('Место должно быть целым числом или пустым полем.');
            }
            var place = parseInt(text, 10);
            if (!place || place < 1) {
                throw new Error('Место должно быть числом от 1 или пустым полем.');
            }
            if (place > count + 1) {
                return count;
            }
            return place - 1;
        }
        function findParentDefaults(info) {
            var result = {
                section: '',
                level: '',
                place: ''
             };
             };


             if (plcontinue) {
             if (!latestCatalogData || !info || !info.from) {
                 params.plcontinue = plcontinue;
                 return result;
             }
             }


             return api.get(params).then(function (data) {
             var parent = latestCatalogData.rowsByKey[titleKey(info.from)];
                var pages = data.query && data.query.pages ? data.query.pages : [];
                var page = pages[0];


                if (!shouldFollowPage(page)) {
            if (!parent) {
                    return {
                return result;
                        title: normalizedTitle,
            }
                        exists: !!page && page.missing === undefined,
 
                        followed: false,
            result.section = parent.section || '';
                         links: []
            result.level = String(Math.min(5, normalizeLevel(parent.level + 1)));
                     };
 
            if (parent.position) {
                result.place = String(parent.position + 1);
            }
 
            return result;
        }
 
        function showModalError(modal, text) {
            var error = modal.querySelector('.scmc-modal-error');
            if (error) error.textContent = text || '';
        }
 
        function closeModal() {
            var old = document.querySelector('.scmc-modal-backdrop');
            if (old) old.remove();
        }
 
        function openAddCandidateModal(info, button) {
            closeModal();
 
            var defaults = findParentDefaults(info);
            var backdrop = makeEl('div', 'scmc-modal-backdrop');
            var modal = makeEl('div', 'scmc-modal');
            var title = makeEl('div', 'scmc-modal-title', 'Добавить страницу в каталог');
            var subtitle = makeEl('div', 'scmc-modal-subtitle', 'Кандидат найден из: ' + (info.from || 'неизвестно'));
            var form = makeEl('div', 'scmc-modal-form');
            var error = makeEl('div', 'scmc-modal-error');
            var actions = makeEl('div', 'scmc-modal-actions');
            var cancelButton = makeButton('Отмена', 'scmc-modal-btn scmc-modal-btn-secondary');
            var submitButton = makeButton('Добавить', 'scmc-modal-btn');
 
            function makeField(labelText, input) {
                var field = makeEl('label', 'scmc-modal-field');
                var label = makeEl('span', '', labelText);
                field.appendChild(label);
                field.appendChild(input);
                return field;
            }
 
            var pageInput = document.createElement('input');
            pageInput.type = 'text';
            pageInput.value = info.finalTitle || info.requestedTitle || '';
            pageInput.readOnly = true;
 
            var titleInput = document.createElement('input');
            titleInput.type = 'text';
            titleInput.value = titleFromPage(info.finalTitle || info.requestedTitle || '');
            titleInput.placeholder = 'Название страницы в каталоге';
 
            var sectionSelect = document.createElement('select');
            sectionSelect.appendChild(new Option('Выберите раздел', ''));
            (latestCatalogData && latestCatalogData.sections ? latestCatalogData.sections : []).forEach(function (section) {
                sectionSelect.appendChild(new Option(section, section));
            });
            sectionSelect.value = defaults.section || '';
 
            var levelSelect = document.createElement('select');
            levelSelect.appendChild(new Option('Выберите уровень', ''));
            [1, 2, 3, 4, 5].forEach(function (level) {
                levelSelect.appendChild(new Option('Уровень ' + level, String(level)));
            });
            levelSelect.value = defaults.level || '';
 
            var statusSelect = document.createElement('select');
            statusSelect.appendChild(new Option('Выберите статус', ''));
            statusSelect.appendChild(new Option('🟢 Готово', 'green'));
            statusSelect.appendChild(new Option('🟡 Нужно обновить', 'yellow'));
            statusSelect.appendChild(new Option('🔴 Нет страницы', 'red'));
            statusSelect.appendChild(new Option('🔵 Заморожено', 'blue'));
 
            var placeInput = document.createElement('input');
            placeInput.type = 'text';
            placeInput.inputMode = 'numeric';
            placeInput.placeholder = 'Пусто = в конец, число = место в каталоге';
            placeInput.value = defaults.place || '';
 
            var scanLabel = makeEl('label', 'scmc-modal-check');
            var scanInput = document.createElement('input');
            scanInput.type = 'checkbox';
            scanLabel.appendChild(scanInput);
            scanLabel.appendChild(document.createTextNode(' Scan OFF'));
 
            var noteInput = document.createElement('textarea');
            noteInput.rows = 3;
            noteInput.placeholder = 'Заметка, если нужна';
 
            form.appendChild(makeField('Страница', pageInput));
            form.appendChild(makeField('Название', titleInput));
            form.appendChild(makeField('Раздел *', sectionSelect));
            form.appendChild(makeField('Уровень *', levelSelect));
            form.appendChild(makeField('Статус *', statusSelect));
            form.appendChild(makeField('Место', placeInput));
            form.appendChild(scanLabel);
            form.appendChild(makeField('Заметка', noteInput));
 
            actions.appendChild(cancelButton);
            actions.appendChild(submitButton);
 
            modal.appendChild(title);
            modal.appendChild(subtitle);
            modal.appendChild(form);
            modal.appendChild(error);
            modal.appendChild(actions);
            backdrop.appendChild(modal);
            document.body.appendChild(backdrop);
 
            cancelButton.addEventListener('click', closeModal);
            backdrop.addEventListener('click', function (event) {
                if (event.target === backdrop) closeModal();
            });
 
            submitButton.addEventListener('click', function () {
                var values = {
                    page: normalizeTitle(pageInput.value),
                    title: normalizeTitle(titleInput.value) || titleFromPage(pageInput.value),
                    section: normalizeTitle(sectionSelect.value),
                    level: levelSelect.value,
                    status: statusSelect.value,
                    place: placeInput.value,
                    scan: scanInput.checked ? 'stop' : '',
                    note: normalizeTitle(noteInput.value)
                };
 
                if (!values.section) {
                    showModalError(modal, 'Выберите раздел.');
                    return;
                }
 
                if (!values.level) {
                    showModalError(modal, 'Выберите уровень.');
                    return;
                }
 
                if (!values.status) {
                    showModalError(modal, 'Выберите статус.');
                    return;
                }
 
                showModalError(modal, '');
                submitButton.disabled = true;
                cancelButton.disabled = true;
                submitButton.textContent = 'Сохраняю...';
 
                addCandidateToCatalog(values).then(function () {
                    closeModal();
 
                    if (button) {
                         button.textContent = '✓ Добавлено';
                        button.disabled = true;
                        button.classList.add('is-added');
                    }
                }).catch(function (err) {
                    submitButton.disabled = false;
                    cancelButton.disabled = false;
                    submitButton.textContent = 'Добавить';
                    showModalError(modal, err && err.message ? err.message : 'Не удалось добавить страницу. Проверь права редактора.');
                });
            });
        }
 
        function rebuildLatestCatalogIndexes() {
            if (!latestCatalogData || !latestCatalogData.catalogRows) return;
 
            latestCatalogData.rowsByKey = latestCatalogData.rowsByKey || {};
 
            latestCatalogData.catalogRows.forEach(function (rowData, index) {
                rowData.position = index + 1;
 
                latestCatalogData.rowsByKey[titleKey(rowData.page)] = rowData;
 
                if (rowData.finalTitle) {
                     latestCatalogData.rowsByKey[titleKey(rowData.finalTitle)] = rowData;
                 }
                 }
            });
        }
        function addCandidateToCatalog(values) {
            var api = getApi();


                if (page.links) {
            return getCatalogSource(api).then(function (source) {
                    page.links.forEach(function (link) {
                var rows = getCatalogRowsFromSource(source);
                        if (link.ns === 0 && !isExcludedTitle(link.title)) {
 
                            links.push(normalizeTitle(link.title));
                if (!rows.length) {
                        }
                    throw new Error('Не нашёл строки каталога в коде страницы.');
                    });
                 }
                 }


                 if (data.continue && data.continue.plcontinue) {
                 var pageKey = titleKey(values.page);
                     return request(data.continue.plcontinue);
                var duplicate = rows.some(function (row) {
                     return titleKey(row.data.page) === pageKey;
                });
 
                if (duplicate) {
                    throw new Error('Эта страница уже есть в каталоге.');
                 }
                 }


                 return {
                 var insertIndex = normalizePlaceInput(values.place, rows.length);
                     title: normalizeTitle(page.title || normalizedTitle),
                var newLine = buildCatalogRowLine(values);
                    exists: true,
                var lines = rows.map(function (row) {
                     followed: true,
                    return row.full;
                     links: uniqueSorted(links)
                });
                 };
 
                lines.splice(insertIndex, 0, newLine);
 
                var first = rows[0];
                var last = rows[rows.length - 1];
                var newText = source.slice(0, first.start) + lines.join('\n') + source.slice(last.end);
 
                return saveCatalogSource(api, newText).then(function () {
                     var rowData = {
                        page: values.page,
                        title: values.title,
                        section: values.section,
                        level: normalizeLevel(values.level),
                        position: insertIndex + 1,
                        scan: values.scan,
                        status: values.status,
                        finalTitle: values.page
                    };
 
                     if (latestCatalogData) {
                        latestCatalogData.catalogRows.splice(insertIndex, 0, rowData);
                        latestCatalogData.knownPages[titleKey(values.page)] = values.page;
                        latestCatalogData.rowsByKey[titleKey(values.page)] = rowData;
 
                        if (values.scan === 'stop') {
                            latestCatalogData.stopPages[titleKey(values.page)] = true;
                        }
 
                        if (latestCatalogData.sections.indexOf(values.section) === -1) {
                            latestCatalogData.sections.push(values.section);
                        }
 
                        rebuildLatestCatalogIndexes();
                     }
                });
            });
        }
 
 
        function isExcludedTitle(title) {
            var clean = normalizeTitle(title);
 
            if (!clean) return true;
 
            if (clean.indexOf('#') !== -1) {
                clean = normalizeTitle(clean.split('#')[0]);
            }
 
            if (extraExcludedTitles.some(function (excluded) {
                return titleKey(excluded) === titleKey(clean);
            })) {
                 return true;
            }
 
            return EXCLUDED_PREFIXES.some(function (prefix) {
                return clean.indexOf(prefix) === 0;
             });
             });
         }
         }


         return request();
         function isValidLink(link) {
    }
            if (!link || !link.title) return false;
            if (typeof link.ns === 'number' && link.ns !== 0) return false;
            if (isExcludedTitle(link.title)) return false;
            return true;
        }
 
        function getPageLinks(api, requestedTitle) {
            var links = [];
            var redirects = [];
            var normalizedRequested = normalizeTitle(requestedTitle);
 
            function request(plcontinue) {
                var params = {
                    action: 'query',
                    prop: 'links',
                    titles: normalizedRequested,
                    pllimit: 'max',
                    redirects: 1,
                    formatversion: 2
                };
 
                if (plcontinue) {
                    params.plcontinue = plcontinue;
                }
 
                return api.get(params).then(function (data) {
                    if (data.query && data.query.redirects) {
                        data.query.redirects.forEach(function (redirect) {
                            redirects.push({
                                from: normalizeTitle(redirect.from),
                                to: normalizeTitle(redirect.to)
                            });
                        });
                    }
 
                    var pages = data.query && data.query.pages ? data.query.pages : [];
                    var page = pages[0];
 
                    if (!page) {
                        return {
                            requestedTitle: normalizedRequested,
                            finalTitle: normalizedRequested,
                            exists: false,
                            links: [],
                            redirects: redirects
                        };
                    }
 
                    var finalTitle = normalizeTitle(page.title || normalizedRequested);
 
                    if (page.missing !== undefined) {
                        return {
                            requestedTitle: normalizedRequested,
                            finalTitle: finalTitle,
                            exists: false,
                            links: [],
                            redirects: redirects
                        };
                    }
 
                    if (typeof page.ns === 'number' && page.ns !== 0) {
                        return {
                            requestedTitle: normalizedRequested,
                            finalTitle: finalTitle,
                            exists: true,
                            links: [],
                            redirects: redirects
                        };
                    }
 
                    if (page.links) {
                        page.links.forEach(function (link) {
                            if (isValidLink(link)) {
                                links.push(normalizeTitle(link.title));
                            }
                        });
                    }
 
                    if (data.continue && data.continue.plcontinue) {
                        return request(data.continue.plcontinue);
                    }
 
                    return {
                        requestedTitle: normalizedRequested,
                        finalTitle: finalTitle,
                        exists: true,
                        links: uniqueSorted(links),
                        redirects: redirects
                    };
                });
            }


    function buildScanner(container) {
            return request();
        var rootTitle = normalizeTitle(container.getAttribute('data-root') || 'Marine_Corps');
         }
        var maxDepth = parseInt(container.getAttribute('data-depth') || '5', 10);
         var maxPages = parseInt(container.getAttribute('data-max-pages') || '250', 10);


         var header = createEl('div', 'scmc-scan-header');
         var header = makeEl('div', 'scmc-scan-header');
         var title = createEl('div', 'scmc-scan-title', 'Сканер ссылок Marine Corps');
         var title = makeEl('div', 'scmc-scan-title', 'Сканер ссылок Marine Corps');
         var subtitle = createEl('div', 'scmc-scan-subtitle', 'Стартовая страница: ' + rootTitle + '. Глубина: ' + maxDepth + '.');
         var subtitle = makeEl(
            'div',
            'scmc-scan-subtitle',
            'Старт: ' + rootTitle + ' · каталог: ' + catalogTitle + ' · глубина: ' + maxDepth + ' · лимит страниц: ' + maxPages
        );


         header.appendChild(title);
         header.appendChild(title);
         header.appendChild(subtitle);
         header.appendChild(subtitle);


         var controls = createEl('div', 'scmc-scan-controls');
         var controls = makeEl('div', 'scmc-scan-controls');
         var startButton = createButton('Сканировать ссылки');
         var scanBtn = makeButton('Сканировать ссылки');
         var clearButton = createButton('Очистить', 'scmc-scan-btn scmc-scan-btn-secondary');
         var clearBtn = makeButton('Очистить', 'scmc-scan-btn scmc-scan-btn-secondary');


         controls.appendChild(startButton);
         controls.appendChild(scanBtn);
         controls.appendChild(clearButton);
         controls.appendChild(clearBtn);


         var status = createEl('div', 'scmc-scan-status', STATUS.waiting);
         var statusBox = makeEl('div', 'scmc-scan-status', 'Ожидает запуска');
         var stats = createEl('div', 'scmc-scan-stats');
         var statsBox = makeEl('div', 'scmc-scan-stats');


         var result = createEl('div', 'scmc-scan-result');
         var resultWrap = makeEl('div', 'scmc-scan-result');
         var treeBox = createEl('div', 'scmc-scan-box');
         var treeBox = makeEl('div', 'scmc-scan-box');
         var listBox = createEl('div', 'scmc-scan-box');
         var listBox = makeEl('div', 'scmc-scan-box');
        var candidatesBox = makeEl('div', 'scmc-scan-box scmc-scan-box-wide');
        var redirectBox = makeEl('div', 'scmc-scan-box scmc-scan-box-wide');


         result.appendChild(treeBox);
         resultWrap.appendChild(treeBox);
         result.appendChild(listBox);
         resultWrap.appendChild(listBox);
        resultWrap.appendChild(candidatesBox);
        resultWrap.appendChild(redirectBox);


         container.innerHTML = '';
         container.innerHTML = '';
         container.appendChild(header);
         container.appendChild(header);
         container.appendChild(controls);
         container.appendChild(controls);
         container.appendChild(status);
         container.appendChild(statusBox);
         container.appendChild(stats);
         container.appendChild(statsBox);
         container.appendChild(result);
         container.appendChild(resultWrap);


         function setStatus(text, mode) {
         function setStatus(text, mode) {
             status.textContent = text;
             statusBox.textContent = text;
             status.setAttribute('data-mode', mode || '');
             statusBox.setAttribute('data-mode', mode || '');
         }
         }


         function clearResult() {
         function clearResults() {
             treeBox.innerHTML = '';
             treeBox.innerHTML = '';
             listBox.innerHTML = '';
             listBox.innerHTML = '';
             stats.innerHTML = '';
             candidatesBox.innerHTML = '';
             setStatus(STATUS.waiting, '');
            redirectBox.innerHTML = '';
            statsBox.innerHTML = '';
             setStatus('Ожидает запуска', '');
         }
         }


         function renderList(allPages, pageDepth, skippedByLimit) {
         function renderTree(root, childrenMap, pageInfo, firstParent, alsoLinkedFrom) {
            treeBox.innerHTML = '';
            treeBox.appendChild(makeEl('div', 'scmc-scan-box-title', 'Дерево известных страниц'));
 
            function makeNode(title, path) {
                var key = titleKey(title);
                var info = pageInfo[key];
 
                var node = makeEl('div', 'scmc-scan-node');
                var line = makeEl('div', 'scmc-scan-node-line');
 
                var link = document.createElement('a');
                link.href = titleToUrl(title);
                link.textContent = title;
 
                line.appendChild(link);
 
                if (info) {
                    line.appendChild(makeEl('span', 'scmc-scan-depth', 'ур. ' + info.depth));
 
                    if (info.requestedTitle && titleKey(info.requestedTitle) !== titleKey(info.finalTitle)) {
                        line.appendChild(makeEl('span', 'scmc-scan-redirect-mini', info.requestedTitle + ' → ' + info.finalTitle));
                    }
 
                    if (info.candidate) {
                        line.appendChild(makeEl('span', 'scmc-scan-candidate', 'кандидат'));
                    }
 
                    if (info.catalogStop) {
                        line.appendChild(makeEl('span', 'scmc-scan-stopped', 'остановлено каталогом'));
                    }
 
                    if (info.depthStop) {
                        line.appendChild(makeEl('span', 'scmc-scan-stopped', 'предел глубины'));
                    }
 
                    if (info.exists === false) {
                        line.appendChild(makeEl('span', 'scmc-scan-missing', 'нет страницы'));
                    }
                }
 
                node.appendChild(line);
 
                if (path[key]) {
                    node.appendChild(makeEl('div', 'scmc-scan-loop', '↳ уже встречалась выше'));
                    return node;
                }
 
                var extraLinks = alsoLinkedFrom[key] || [];
                if (extraLinks.length) {
                    node.appendChild(makeEl('div', 'scmc-scan-also', 'Ещё ссылки из: ' + extraLinks.join(', ')));
                }
 
                var nextPath = Object.assign({}, path);
                nextPath[key] = true;
 
                var children = childrenMap[key] || [];
 
                if (children.length) {
                    var childrenWrap = makeEl('div', 'scmc-scan-children');
 
                    children.forEach(function (childTitle) {
                        var childKey = titleKey(childTitle);
 
                        if (firstParent[childKey] && titleKey(firstParent[childKey]) !== key) {
                            var refNode = makeEl('div', 'scmc-scan-node');
                            var refLine = makeEl('div', 'scmc-scan-node-line scmc-scan-node-ref');
 
                            var refLink = document.createElement('a');
                            refLink.href = titleToUrl(childTitle);
                            refLink.textContent = childTitle;
 
                            refLine.appendChild(refLink);
                            refLine.appendChild(makeEl('span', 'scmc-scan-ref', 'уже найдено в: ' + firstParent[childKey]));
                            refNode.appendChild(refLine);
                            childrenWrap.appendChild(refNode);
                            return;
                        }
 
                        childrenWrap.appendChild(makeNode(childTitle, nextPath));
                    });
 
                    node.appendChild(childrenWrap);
                }
 
                return node;
            }
 
            treeBox.appendChild(makeNode(root, {}));
        }
 
        function renderKnownList(pageInfo) {
             listBox.innerHTML = '';
             listBox.innerHTML = '';
            listBox.appendChild(makeEl('div', 'scmc-scan-box-title', 'Известные страницы из каталога'));


             var boxTitle = createEl('div', 'scmc-scan-box-title', 'Все найденные страницы');
             var pages = Object.keys(pageInfo)
            listBox.appendChild(boxTitle);
                .map(function (key) {
                    return pageInfo[key];
                })
                .filter(function (info) {
                    return !info.candidate;
                })
                .sort(function (a, b) {
                    if (a.depth !== b.depth) return a.depth - b.depth;
                    return a.finalTitle.localeCompare(b.finalTitle, 'ru');
                });


             var list = createEl('ol', 'scmc-scan-page-list');
             if (!pages.length) {
                listBox.appendChild(makeEl('div', 'scmc-scan-empty', 'Пока пусто.'));
                return;
            }


             allPages.forEach(function (title) {
             var list = makeEl('ol', 'scmc-scan-page-list');
 
            pages.forEach(function (info) {
                 var li = document.createElement('li');
                 var li = document.createElement('li');
                var a = document.createElement('a');
                a.href = titleToLink(title);
                a.textContent = title;


                 var depth = createEl('span', 'scmc-scan-depth', 'ур. ' + pageDepth[title]);
                 var link = document.createElement('a');
                link.href = titleToUrl(info.finalTitle);
                link.textContent = info.finalTitle;
 
                li.appendChild(link);
                li.appendChild(makeEl('span', 'scmc-scan-depth', 'ур. ' + info.depth));
 
                if (info.requestedTitle && titleKey(info.requestedTitle) !== titleKey(info.finalTitle)) {
                    li.appendChild(makeEl('span', 'scmc-scan-redirect-mini', 'найдено как: ' + info.requestedTitle));
                }
 
                if (info.catalogStop) {
                    li.appendChild(makeEl('span', 'scmc-scan-stopped', 'остановлено каталогом'));
                }
 
                if (info.depthStop) {
                    li.appendChild(makeEl('span', 'scmc-scan-stopped', 'предел глубины'));
                }


                li.appendChild(a);
                li.appendChild(depth);
                 list.appendChild(li);
                 list.appendChild(li);
             });
             });


             listBox.appendChild(list);
             listBox.appendChild(list);
        }
        function renderCandidates(candidates) {
            candidatesBox.innerHTML = '';
            candidatesBox.appendChild(makeEl('div', 'scmc-scan-box-title', 'Кандидаты: ссылки не из каталога'));
            var pages = Object.keys(candidates)
                .map(function (key) {
                    return candidates[key];
                })
                .sort(function (a, b) {
                    if (a.depth !== b.depth) return a.depth - b.depth;
                    return a.finalTitle.localeCompare(b.finalTitle, 'ru');
                });


             if (skippedByLimit) {
             if (!pages.length) {
                 var warning = createEl('div', 'scmc-scan-warning', 'Сканирование остановлено по лимиту страниц. Если нужно больше — увеличь data-max-pages.');
                 candidatesBox.appendChild(makeEl('div', 'scmc-scan-empty', 'Новых кандидатов не найдено.'));
                 listBox.appendChild(warning);
                 return;
             }
             }
        }


        function renderTree(root, childrenMap, pageDepth) {
            var list = makeEl('ol', 'scmc-scan-page-list');
            treeBox.innerHTML = '';
 
            pages.forEach(function (info) {
                var li = document.createElement('li');
                var link = document.createElement('a');
                var addButton = makeButton('+ В каталог', 'scmc-scan-add-btn');


            var boxTitle = createEl('div', 'scmc-scan-box-title', 'Дерево ссылок');
                link.href = titleToUrl(info.finalTitle);
            treeBox.appendChild(boxTitle);
                link.textContent = info.finalTitle;


            function makeNode(title, localVisited) {
                addButton.addEventListener('click', function () {
                var node = createEl('div', 'scmc-scan-node');
                    openAddCandidateModal(info, addButton);
                });


                 var line = createEl('div', 'scmc-scan-node-line');
                 li.appendChild(link);
                 var a = document.createElement('a');
                li.appendChild(makeEl('span', 'scmc-scan-depth', 'ур. ' + info.depth));
                a.href = titleToLink(title);
                 li.appendChild(makeEl('span', 'scmc-scan-candidate', 'не сканировалась'));
                a.textContent = title;


                var depth = createEl('span', 'scmc-scan-depth', 'ур. ' + pageDepth[title]);
if (info.from) {
  var fromRef = makeEl('span', 'scmc-scan-ref');
 
  fromRef.appendChild(document.createTextNode('найдена из: '));
  fromRef.appendChild(makePageLink(info.from, info.from, 'scmc-scan-ref-link'));
  fromRef.appendChild(document.createTextNode(' · '));
  fromRef.appendChild(makeEditLink(info.from, 'код', 'scmc-scan-ref-link scmc-scan-ref-edit'));
 
  li.appendChild(fromRef);
}


                 line.appendChild(a);
                 if (info.requestedTitle && titleKey(info.requestedTitle) !== titleKey(info.finalTitle)) {
                line.appendChild(depth);
                    li.appendChild(makeEl('span', 'scmc-scan-redirect-mini', info.requestedTitle + ' → ' + info.finalTitle));
                 node.appendChild(line);
                 }


                 if (localVisited[title]) {
                 if (info.exists === false) {
                     var loop = createEl('div', 'scmc-scan-loop', '↳ уже встречалась выше');
                     li.appendChild(makeEl('span', 'scmc-scan-missing', 'нет страницы'));
                    node.appendChild(loop);
                    return node;
                 }
                 }


                 var nextVisited = Object.assign({}, localVisited);
                 li.appendChild(addButton);
                 nextVisited[title] = true;
                 list.appendChild(li);
            });


                var children = childrenMap[title] || [];
            candidatesBox.appendChild(list);
                if (children.length) {
        }
                    var childBox = createEl('div', 'scmc-scan-children');


                    children.forEach(function (child) {
        function renderRedirects(redirectsFound) {
                        childBox.appendChild(makeNode(child, nextVisited));
            redirectBox.innerHTML = '';
                    });
            redirectBox.appendChild(makeEl('div', 'scmc-scan-box-title', 'Редиректы'));


                    node.appendChild(childBox);
            var keys = Object.keys(redirectsFound).sort(function (a, b) {
                }
                return a.localeCompare(b, 'ru');
            });


                 return node;
            if (!keys.length) {
                redirectBox.appendChild(makeEl('div', 'scmc-scan-empty', 'Редиректы не найдены.'));
                 return;
             }
             }


             treeBox.appendChild(makeNode(root, {}));
             var list = makeEl('ol', 'scmc-scan-page-list');
 
            keys.forEach(function (from) {
                var to = redirectsFound[from];
                var li = document.createElement('li');
 
                var fromLink = document.createElement('a');
                fromLink.href = titleToUrl(from);
                fromLink.textContent = from;
 
                var toLink = document.createElement('a');
                toLink.href = titleToUrl(to);
                toLink.textContent = to;
 
                li.appendChild(fromLink);
                li.appendChild(document.createTextNode(' → '));
                li.appendChild(toLink);
 
                list.appendChild(li);
            });
 
            redirectBox.appendChild(list);
         }
         }


         function scan() {
         function scan() {
             clearResult();
             clearResults();


             var api = getApi();
             var api = getApi();
            var queue = [{ title: rootTitle, depth: 0 }];
            var scanned = {};
            var queued = {};
            var pageDepth = {};
            var childrenMap = {};
            var skippedByLimit = false;


             queued[rootTitle] = true;
             scanBtn.disabled = true;
             pageDepth[rootTitle] = 0;
             clearBtn.disabled = true;
 
            setStatus('Читаю каталог: ' + catalogTitle, 'scanning');
 
            loadCatalog(api, catalogTitle).then(function (catalog) {
                latestCatalogData = catalog;


            startButton.disabled = true;
                var knownPages = catalog.knownPages;
            clearButton.disabled = true;
                var stopPages = catalog.stopPages;
            setStatus(STATUS.scanning, 'scanning');
                var redirectsFound = Object.assign({}, catalog.redirects);


            function step() {
                var queue = [{
                if (!queue.length) {
                    requestedTitle: rootTitle,
                    finish();
                    depth: 0,
                     return;
                    parent: null
                }];
 
                var scanned = {};
                var queued = {};
                var pageInfo = {};
                var candidates = {};
                var childrenMap = {};
                var firstParent = {};
                var alsoLinkedFrom = {};
                var stoppedByLimit = false;
 
                queued[titleKey(rootTitle)] = true;
 
                function addRedirects(redirects) {
                    redirects.forEach(function (redirect) {
                        if (redirect.from && redirect.to) {
                            redirectsFound[redirect.from] = redirect.to;
                        }
                     });
                 }
                 }


                 if (Object.keys(scanned).length >= maxPages) {
                 function addAlsoLinked(childTitle, parentTitle) {
                    skippedByLimit = true;
                    var childKey = titleKey(childTitle);
                     finish();
 
                     return;
                    if (!alsoLinkedFrom[childKey]) {
                        alsoLinkedFrom[childKey] = [];
                     }
 
                    if (alsoLinkedFrom[childKey].indexOf(parentTitle) === -1) {
                        alsoLinkedFrom[childKey].push(parentTitle);
                     }
                 }
                 }


                 var current = queue.shift();
                 function upsertPageInfo(finalTitle, requestedTitle, depth, exists, options) {
                var title = current.title;
                    var finalKey = titleKey(finalTitle);
                var depth = current.depth;
                    var current = pageInfo[finalKey];


                if (scanned[title]) {
                    if (!current) {
                     step();
                        current = {
                     return;
                            requestedTitle: requestedTitle || finalTitle,
                            finalTitle: finalTitle,
                            depth: depth,
                            exists: exists
                        };
 
                        pageInfo[finalKey] = current;
                    }
 
                    if (depth < current.depth) {
                        current.depth = depth;
                    }
 
                    if (requestedTitle && titleKey(requestedTitle) !== titleKey(finalTitle)) {
                        current.requestedTitle = requestedTitle;
                    }
 
                    if (exists === false) {
                        current.exists = false;
                     }
 
                    if (options) {
                        Object.keys(options).forEach(function (key) {
                            current[key] = options[key];
                        });
                    }
 
                     return current;
                 }
                 }


                 scanned[title] = true;
                 function step() {
                    if (!queue.length) {
                        finish();
                        return;
                    }
 
                    if (Object.keys(scanned).length >= maxPages) {
                        stoppedByLimit = true;
                        finish();
                        return;
                    }
 
                    var item = queue.shift();
                    var requestedTitle = normalizeTitle(item.requestedTitle);
                    var requestedKey = titleKey(requestedTitle);
 
                    if (scanned[requestedKey]) {
                        step();
                        return;
                    }
 
                    scanned[requestedKey] = true;
 
                    setStatus('Сканирую: ' + requestedTitle + ' · уровень ' + item.depth, 'scanning');
 
                    getPageLinks(api, requestedTitle).then(function (data) {
                        addRedirects(data.redirects);
 
                        var finalTitle = normalizeTitle(data.finalTitle || requestedTitle);
                        var finalKey = titleKey(finalTitle);
                        var isRoot = item.depth === 0;
                        var isKnown = !!knownPages[finalKey] || !!knownPages[requestedKey] || isRoot;
                        var isCatalogStop = !isRoot && (!!stopPages[finalKey] || !!stopPages[requestedKey]);
 
                        upsertPageInfo(finalTitle, requestedTitle, item.depth, data.exists, {
                            catalogStop: isCatalogStop
                        });
 
                        if (!isKnown) {
                            candidates[finalKey] = {
                                requestedTitle: requestedTitle,
                                finalTitle: finalTitle,
                                depth: item.depth,
                                exists: data.exists,
                                from: item.parent || '',
                                candidate: true
                            };
 
                            upsertPageInfo(finalTitle, requestedTitle, item.depth, data.exists, {
                                candidate: true
                            });
 
                            setTimeout(step, 80);
                            return;
                        }
 
                        if (isCatalogStop) {
                            childrenMap[finalKey] = [];
                            setTimeout(step, 80);
                            return;
                        }
 
                        if (item.depth >= maxDepth) {
                            pageInfo[finalKey].depthStop = true;
                            childrenMap[finalKey] = [];
                            setTimeout(step, 80);
                            return;
                        }
 
                        resolveTitles(api, data.links).then(function (resolvedLinks) {
                            Object.keys(resolvedLinks.redirects).forEach(function (from) {
                                redirectsFound[from] = resolvedLinks.redirects[from];
                            });
 
                            var children = [];
 
                            data.links.forEach(function (originalLinkTitle) {
                                var originalKey = titleKey(originalLinkTitle);
                                var resolved = resolvedLinks.items[originalKey];
 
                                var childRequested = resolved ? resolved.requestedTitle : originalLinkTitle;
                                var childFinal = resolved ? resolved.finalTitle : originalLinkTitle;
                                var childExists = resolved ? resolved.exists : null;
                                var childKey = titleKey(childFinal);
 
                                if (isExcludedTitle(childRequested) || isExcludedTitle(childFinal)) {
                                    return;
                                }
 
                                var childKnown = !!knownPages[childKey] || !!knownPages[originalKey];
                                var childStop = !!stopPages[childKey] || !!stopPages[originalKey];
 
                                children.push(childFinal);
 
                                if (!firstParent[childKey]) {
                                    firstParent[childKey] = finalTitle;
                                } else if (titleKey(firstParent[childKey]) !== finalKey) {
                                    addAlsoLinked(childFinal, finalTitle);
                                }
 
                                if (childKnown) {
                                    upsertPageInfo(childFinal, childRequested, item.depth + 1, childExists, {
                                        catalogStop: childStop
                                    });
 
                                    if (!childStop && !queued[childKey]) {
                                        queued[childKey] = true;


                setStatus('Сканирую: ' + title + ' — уровень ' + depth, 'scanning');
                                        queue.push({
                                            requestedTitle: childFinal,
                                            depth: item.depth + 1,
                                            parent: finalTitle
                                        });
                                    }
                                } else {
                                    candidates[childKey] = {
                                        requestedTitle: childRequested,
                                        finalTitle: childFinal,
                                        depth: item.depth + 1,
                                        exists: childExists,
                                        from: finalTitle,
                                        candidate: true
                                    };


                getPageLinks(api, title).then(function (pageData) {
                                    upsertPageInfo(childFinal, childRequested, item.depth + 1, childExists, {
                    childrenMap[title] = pageData.links || [];
                                        candidate: true
                                    });
                                }
                            });


                    if (depth < maxDepth) {
                            childrenMap[finalKey] = uniqueSorted(children);
                        pageData.links.forEach(function (linkTitle) {
 
                            if (!queued[linkTitle] && !isExcludedTitle(linkTitle)) {
                            statsBox.textContent =
                                 queued[linkTitle] = true;
                                'Каталог: ' + Object.keys(knownPages).length +
                                 pageDepth[linkTitle] = depth + 1;
                                ' · Просканировано: ' + Object.keys(scanned).length +
                                 queue.push({
                                 ' · В очереди: ' + queue.length +
                                    title: linkTitle,
                                 ' · Известных найдено: ' + Object.keys(pageInfo).length +
                                    depth: depth + 1
                                 ' · Кандидатов: ' + Object.keys(candidates).length +
                                });
                                ' · Редиректов: ' + Object.keys(redirectsFound).length;
                             }
 
                             setTimeout(step, 100);
                         });
                         });
                     }
                     }).catch(function (error) {
                        console.error(error);
 
                        setStatus('Ошибка при сканировании: ' + requestedTitle, 'error');
 
                        scanBtn.disabled = false;
                        clearBtn.disabled = false;
                    });
                }
 
                function finish() {
                    var rootFinal = rootTitle;
 
                    Object.keys(pageInfo).some(function (key) {
                        var info = pageInfo[key];
 
                        if (titleKey(info.requestedTitle) === titleKey(rootTitle) || titleKey(info.finalTitle) === titleKey(rootTitle)) {
                            rootFinal = info.finalTitle;
                            return true;
                        }
 
                        return false;
                    });
 
                    renderTree(rootFinal, childrenMap, pageInfo, firstParent, alsoLinkedFrom);
                    renderKnownList(pageInfo);
                    renderCandidates(candidates);
                    renderRedirects(redirectsFound);


                     stats.textContent = 'Просканировано страниц: ' + Object.keys(scanned).length + '. В очереди: ' + queue.length + '. Найдено всего: ' + Object.keys(queued).length + '.';
                     var finalText =
                        'Готово. Каталог прочитан. Просканировано: ' + Object.keys(scanned).length +
                        '. Кандидатов: ' + Object.keys(candidates).length +
                        '. Редиректов: ' + Object.keys(redirectsFound).length + '.';


                     setTimeout(step, 120);
                     if (stoppedByLimit) {
                }).catch(function (error) {
                        finalText += ' Остановлено по лимиту страниц.';
                    console.error(error);
                     }
                    setStatus('Ошибка при сканировании страницы: ' + title, 'error');
                    startButton.disabled = false;
                     clearButton.disabled = false;
                });
            }


            function finish() {
                    statsBox.textContent = finalText;
                var allPages = Object.keys(queued).sort(function (a, b) {
                     setStatus('Готово', 'done');
                     return a.localeCompare(b, 'ru');
                });


                renderTree(rootTitle, childrenMap, pageDepth);
                    scanBtn.disabled = false;
                 renderList(allPages, pageDepth, skippedByLimit);
                    clearBtn.disabled = false;
                 }


                 stats.textContent = 'Готово. Найдено страниц: ' + allPages.length + '. Просканировано страниц: ' + Object.keys(scanned).length + '.';
                 step();
                 setStatus(STATUS.done, 'done');
            }).catch(function (error) {
                 console.error(error);


                 startButton.disabled = false;
                 setStatus('Не удалось прочитать каталог: ' + catalogTitle, 'error');
                clearButton.disabled = false;
            }


             step();
                scanBtn.disabled = false;
                clearBtn.disabled = false;
             });
         }
         }


         startButton.addEventListener('click', scan);
         scanBtn.addEventListener('click', scan);
         clearButton.addEventListener('click', clearResult);
         clearBtn.addEventListener('click', clearResults);
     }
     }


     mw.hook('wikipage.content').add(function ($content) {
     buildScanner(scannerRoot);
        $content.find('.scmc-link-scanner').each(function () {
            buildScanner(this);
        });
    });
})();
})();

Текущая версия от 15:12, 15 июня 2026

(function () {
    var scannerRoot = document.querySelector('.scmc-link-scanner');
    if (!scannerRoot) return;

    var EXCLUDED_PREFIXES = [
        'Файл:',
        'File:',
        'Категория:',
        'Category:',
        'Шаблон:',
        'Template:',
        'Участник:',
        'User:',
        'Обсуждение:',
        'Talk:',
        'Служебная:',
        'Special:',
        'MediaWiki:',
        'Модуль:',
        'Module:',
        'Справка:',
        'Help:'
    ];

    function normalizeTitle(title) {
        return String(title || '')
            .replace(/_/g, ' ')
            .replace(/\s+/g, ' ')
            .trim();
    }

    function titleKey(title) {
        return normalizeTitle(title).toLowerCase();
    }

    function titleToUrl(title) {
        return mw.util.getUrl(title);
    }

	function titleToEditUrl(title) {
		return mw.util.getUrl(title, { action: 'edit' });
	}

	function makePageLink(title, text, className) {
		var link = document.createElement('a');
		link.href = titleToUrl(title);
		link.textContent = text || title;

		  if (className) {
			  	link.className = className;
		  }

	  return link;
	}

function makeEditLink(title, text, className) {
    var link = document.createElement('a');
    link.href = titleToEditUrl(title);
    link.textContent = text || 'код';

    if (className) {
        link.className = className;
    }

    link.target = '_blank';
    link.rel = 'noopener';

    return link;
}

    function makeEl(tag, className, text) {
        var el = document.createElement(tag);
        if (className) el.className = className;
        if (text !== undefined) el.textContent = text;
        return el;
    }

    function makeButton(text, className) {
        var btn = document.createElement('button');
        btn.type = 'button';
        btn.className = className || 'scmc-scan-btn';
        btn.textContent = text;
        return btn;
    }

    function splitDataList(value) {
        return String(value || '')
            .split('|')
            .map(normalizeTitle)
            .filter(Boolean);
    }

    function uniqueSorted(list) {
        var seen = {};

        list.forEach(function (item) {
            var title = normalizeTitle(item);
            if (title) seen[titleKey(title)] = title;
        });

        return Object.keys(seen)
            .map(function (key) {
                return seen[key];
            })
            .sort(function (a, b) {
                return a.localeCompare(b, 'ru');
            });
    }

    function chunkArray(items, size) {
        var chunks = [];

        for (var i = 0; i < items.length; i += size) {
            chunks.push(items.slice(i, i + size));
        }

        return chunks;
    }

    function extractAttr(tag, names) {
        for (var i = 0; i < names.length; i++) {
            var name = names[i];
            var re = new RegExp(name + '\\s*=\\s*(["\\\'])(.*?)\\1', 'i');
            var match = tag.match(re);

            if (match) {
                return match[2];
            }
        }

        return '';
    }

    function isStopCatalogRow(tag) {
        var scanValue = normalizeTitle(extractAttr(tag, ['data-scan', 'data_scan'])).toLowerCase();
        var classValue = ' ' + normalizeTitle(extractAttr(tag, ['class'])).toLowerCase() + ' ';

        return scanValue === 'stop' ||
            classValue.indexOf(' scmc-scan-stop ') !== -1 ||
            classValue.indexOf(' scan-stop ') !== -1 ||
            classValue.indexOf(' stop ') !== -1;
    }


    function encodeAttr(value) {
        return String(value || '')
            .replace(/&/g, '&amp;')
            .replace(/"/g, '&quot;')
            .replace(/</g, '&lt;')
            .replace(/>/g, '&gt;');
    }

    function getLevelNumberFromClass(className) {
        var match = String(className || '').match(/\bscmc-level-([1-5])\b/);
        return match ? parseInt(match[1], 10) : 1;
    }

    function normalizeLevel(level) {
        level = parseInt(level, 10);
        if (!level || level < 1) return 1;
        if (level > 5) return 5;
        return level;
    }

    function cleanCatalogClass(level) {
        return 'scmc-catalog-row scmc-level-' + normalizeLevel(level);
    }

    function titleFromPage(page) {
        return normalizeTitle(page).replace(/_/g, ' ');
    }

    function buildCatalogRowLine(data) {
        var page = normalizeTitle(data.page);
        var title = normalizeTitle(data.title || titleFromPage(page));
        var section = normalizeTitle(data.section);
        var level = normalizeLevel(data.level);
        var status = String(data.status || '').trim();
        var scan = data.scan === 'stop' ? 'stop' : '';
        var note = normalizeTitle(data.note || '');
        var out = '<div class="' + encodeAttr(cleanCatalogClass(level)) + '"';

        out += ' data-section="' + encodeAttr(section) + '"';
        out += ' data-page="' + encodeAttr(page) + '"';
        out += ' data-title="' + encodeAttr(title) + '"';

        if (scan) {
            out += ' data-scan="stop"';
        }

        out += ' data-status="' + encodeAttr(status) + '"';

        if (note) {
            out += ' data-note="' + encodeAttr(note) + '"';
        }

        if (/^https?:\/\//i.test(page)) {
            out += '>[' + page + ' ' + title + ']</div>';
        } else {
            out += '>[[' + page + '|' + title + ']]</div>';
        }

        return out;
    }

    function getApi() {
        return new mw.Api();
    }

    function resolveTitles(api, titles) {
        var unique = uniqueSorted(titles);
        var chunks = chunkArray(unique, 45);
        var finalResult = {};
        var allRedirects = {};

        function followRedirect(title, redirectMap) {
            var current = normalizeTitle(title);
            var guard = 0;

            while (redirectMap[titleKey(current)] && guard < 10) {
                current = redirectMap[titleKey(current)];
                guard++;
            }

            return current;
        }

        function processChunk(chunk) {
            if (!chunk.length) return $.Deferred().resolve().promise();

            return api.get({
                action: 'query',
                titles: chunk.join('|'),
                redirects: 1,
                formatversion: 2
            }).then(function (data) {
                var redirectMap = {};
                var pagesByKey = {};

                if (data.query && data.query.redirects) {
                    data.query.redirects.forEach(function (redirect) {
                        var from = normalizeTitle(redirect.from);
                        var to = normalizeTitle(redirect.to);

                        redirectMap[titleKey(from)] = to;
                        allRedirects[from] = to;
                    });
                }

                if (data.query && data.query.pages) {
                    data.query.pages.forEach(function (page) {
                        var pageTitle = normalizeTitle(page.title);

                        pagesByKey[titleKey(pageTitle)] = {
                            title: pageTitle,
                            exists: page.missing === undefined
                        };
                    });
                }

                chunk.forEach(function (requested) {
                    var requestedTitle = normalizeTitle(requested);
                    var finalTitle = followRedirect(requestedTitle, redirectMap);
                    var page = pagesByKey[titleKey(finalTitle)];

                    finalResult[titleKey(requestedTitle)] = {
                        requestedTitle: requestedTitle,
                        finalTitle: page ? page.title : finalTitle,
                        exists: page ? page.exists : null
                    };
                });
            });
        }

        var chain = $.Deferred().resolve().promise();

        chunks.forEach(function (chunk) {
            chain = chain.then(function () {
                return processChunk(chunk);
            });
        });

        return chain.then(function () {
            return {
                items: finalResult,
                redirects: allRedirects
            };
        });
    }

    function loadCatalog(api, catalogTitle) {
        return api.get({
            action: 'parse',
            page: catalogTitle,
            prop: 'wikitext',
            formatversion: 2
        }).then(function (data) {
            var wikitext = '';

            if (data.parse && typeof data.parse.wikitext === 'string') {
                wikitext = data.parse.wikitext;
            } else if (data.parse && data.parse.wikitext && data.parse.wikitext['*']) {
                wikitext = data.parse.wikitext['*'];
            }

            var rawPages = [];
            var rawStopPages = {};
            var catalogRows = [];
            var rowRe = /<div\b[^>]*scmc-catalog-row[^>]*>/gi;
            var match;

            while ((match = rowRe.exec(wikitext)) !== null) {
                var tag = match[0];
                var className = extractAttr(tag, ['class']);
                var page = normalizeTitle(extractAttr(tag, ['data-page', 'data_page']));
                var title = normalizeTitle(extractAttr(tag, ['data-title', 'data_title'])) || page;
                var section = normalizeTitle(extractAttr(tag, ['data-section', 'data_section']));
                var level = getLevelNumberFromClass(className);

                if (!page) continue;

                rawPages.push(page);

                var rowData = {
                    page: page,
                    title: title,
                    section: section,
                    level: level,
                    position: catalogRows.length + 1,
                    scan: isStopCatalogRow(tag) ? 'stop' : '',
                    status: normalizeTitle(extractAttr(tag, ['data-status', 'data_status']))
                };

                catalogRows.push(rowData);

                if (rowData.scan === 'stop') {
                    rawStopPages[titleKey(page)] = true;
                }
            }

            return resolveTitles(api, rawPages).then(function (resolved) {
                var knownPages = {};
                var stopPages = {};
                var redirects = resolved.redirects;
                var rowsByKey = {};
                var sectionMap = {};
                var sections = [];

                catalogRows.forEach(function (rowData) {
                    var rawPage = rowData.page;
                    var rawKey = titleKey(rawPage);
                    var resolvedItem = resolved.items[rawKey];
                    var finalTitle = resolvedItem ? resolvedItem.finalTitle : rawPage;
                    var finalKey = titleKey(finalTitle);
                    var enriched = Object.assign({}, rowData, {
                        finalTitle: finalTitle
                    });

                    knownPages[rawKey] = rawPage;
                    knownPages[finalKey] = finalTitle;
                    rowsByKey[rawKey] = enriched;
                    rowsByKey[finalKey] = enriched;

                    if (rowData.section && !sectionMap[rowData.section]) {
                        sectionMap[rowData.section] = true;
                        sections.push(rowData.section);
                    }

                    if (rawStopPages[rawKey]) {
                        stopPages[rawKey] = true;
                        stopPages[finalKey] = true;
                    }
                });

                var preferredOrder = [
                    'Морпехи',
                    'Ксеноморфы',
                    'Правила и процедуры',
                    'Справочник',
                    'Лор и материалы',
                    'Общее'
                ];

                sections.sort(function (a, b) {
                    var ai = preferredOrder.indexOf(a);
                    var bi = preferredOrder.indexOf(b);

                    if (ai !== -1 || bi !== -1) {
                        if (ai === -1) return 1;
                        if (bi === -1) return -1;
                        return ai - bi;
                    }

                    return a.localeCompare(b, 'ru');
                });

                return {
                    knownPages: knownPages,
                    stopPages: stopPages,
                    redirects: redirects,
                    catalogRows: catalogRows,
                    rowsByKey: rowsByKey,
                    sections: sections,
                    count: Object.keys(knownPages).length
                };
            });
        });
    }

    function buildScanner(container) {
        var rootTitle = normalizeTitle(container.getAttribute('data-root') || 'Marine_Corps');
        var catalogTitle = normalizeTitle(container.getAttribute('data-catalog') || 'MC:Страницы');
        var maxDepth = parseInt(container.getAttribute('data-depth') || '5', 10);
        var maxPages = parseInt(container.getAttribute('data-max-pages') || '300', 10);
        var extraExcludedTitles = splitDataList(container.getAttribute('data-exclude'));

        var latestCatalogData = null;

        function getCatalogSource(api) {
            return api.get({
                action: 'query',
                prop: 'revisions',
                titles: catalogTitle,
                rvprop: 'content',
                rvslots: 'main',
                formatversion: 2
            }).then(function (data) {
                var pageData = data.query && data.query.pages ? data.query.pages[0] : null;
                var rev = pageData && pageData.revisions ? pageData.revisions[0] : null;

                if (rev && rev.slots && rev.slots.main && typeof rev.slots.main.content === 'string') {
                    return rev.slots.main.content;
                }

                if (rev && typeof rev.content === 'string') {
                    return rev.content;
                }

                throw new Error('Не удалось прочитать код каталога.');
            });
        }

        function saveCatalogSource(api, text) {
            return api.postWithToken('csrf', {
                action: 'edit',
                title: catalogTitle,
                text: text,
                summary: 'Добавление страницы в каталог Marine Corps из сканера',
                minor: true,
                formatversion: 2
            });
        }

        function getCatalogRowsFromSource(source) {
            var rows = [];
            var re = /<div\b[^>]*\bscmc-catalog-row\b[^>]*>[\s\S]*?<\/div>/gi;
            var match;

            while ((match = re.exec(source)) !== null) {
                var full = match[0];
                var open = full.match(/^<div\b[^>]*>/i);
                if (!open) continue;

                var tag = open[0];
                var page = normalizeTitle(extractAttr(tag, ['data-page', 'data_page']));
                var title = normalizeTitle(extractAttr(tag, ['data-title', 'data_title'])) || page;
                var section = normalizeTitle(extractAttr(tag, ['data-section', 'data_section']));
                var level = getLevelNumberFromClass(extractAttr(tag, ['class']));

                if (!page) continue;

                rows.push({
                    start: match.index,
                    end: match.index + full.length,
                    full: full,
                    data: {
                        page: page,
                        title: title,
                        section: section,
                        level: level,
                        position: rows.length + 1
                    }
                });
            }

            return rows;
        }

        function normalizePlaceInput(value, count) {
            var text = String(value || '').trim();

            if (!text) {
                return count;
            }

            if (!/^\d+$/.test(text)) {
                throw new Error('Место должно быть целым числом или пустым полем.');
            }

            var place = parseInt(text, 10);

            if (!place || place < 1) {
                throw new Error('Место должно быть числом от 1 или пустым полем.');
            }

            if (place > count + 1) {
                return count;
            }

            return place - 1;
        }

        function findParentDefaults(info) {
            var result = {
                section: '',
                level: '',
                place: ''
            };

            if (!latestCatalogData || !info || !info.from) {
                return result;
            }

            var parent = latestCatalogData.rowsByKey[titleKey(info.from)];

            if (!parent) {
                return result;
            }

            result.section = parent.section || '';
            result.level = String(Math.min(5, normalizeLevel(parent.level + 1)));

            if (parent.position) {
                result.place = String(parent.position + 1);
            }

            return result;
        }

        function showModalError(modal, text) {
            var error = modal.querySelector('.scmc-modal-error');
            if (error) error.textContent = text || '';
        }

        function closeModal() {
            var old = document.querySelector('.scmc-modal-backdrop');
            if (old) old.remove();
        }

        function openAddCandidateModal(info, button) {
            closeModal();

            var defaults = findParentDefaults(info);
            var backdrop = makeEl('div', 'scmc-modal-backdrop');
            var modal = makeEl('div', 'scmc-modal');
            var title = makeEl('div', 'scmc-modal-title', 'Добавить страницу в каталог');
            var subtitle = makeEl('div', 'scmc-modal-subtitle', 'Кандидат найден из: ' + (info.from || 'неизвестно'));
            var form = makeEl('div', 'scmc-modal-form');
            var error = makeEl('div', 'scmc-modal-error');
            var actions = makeEl('div', 'scmc-modal-actions');
            var cancelButton = makeButton('Отмена', 'scmc-modal-btn scmc-modal-btn-secondary');
            var submitButton = makeButton('Добавить', 'scmc-modal-btn');

            function makeField(labelText, input) {
                var field = makeEl('label', 'scmc-modal-field');
                var label = makeEl('span', '', labelText);
                field.appendChild(label);
                field.appendChild(input);
                return field;
            }

            var pageInput = document.createElement('input');
            pageInput.type = 'text';
            pageInput.value = info.finalTitle || info.requestedTitle || '';
            pageInput.readOnly = true;

            var titleInput = document.createElement('input');
            titleInput.type = 'text';
            titleInput.value = titleFromPage(info.finalTitle || info.requestedTitle || '');
            titleInput.placeholder = 'Название страницы в каталоге';

            var sectionSelect = document.createElement('select');
            sectionSelect.appendChild(new Option('Выберите раздел', ''));
            (latestCatalogData && latestCatalogData.sections ? latestCatalogData.sections : []).forEach(function (section) {
                sectionSelect.appendChild(new Option(section, section));
            });
            sectionSelect.value = defaults.section || '';

            var levelSelect = document.createElement('select');
            levelSelect.appendChild(new Option('Выберите уровень', ''));
            [1, 2, 3, 4, 5].forEach(function (level) {
                levelSelect.appendChild(new Option('Уровень ' + level, String(level)));
            });
            levelSelect.value = defaults.level || '';

            var statusSelect = document.createElement('select');
            statusSelect.appendChild(new Option('Выберите статус', ''));
            statusSelect.appendChild(new Option('🟢 Готово', 'green'));
            statusSelect.appendChild(new Option('🟡 Нужно обновить', 'yellow'));
            statusSelect.appendChild(new Option('🔴 Нет страницы', 'red'));
            statusSelect.appendChild(new Option('🔵 Заморожено', 'blue'));

            var placeInput = document.createElement('input');
            placeInput.type = 'text';
            placeInput.inputMode = 'numeric';
            placeInput.placeholder = 'Пусто = в конец, число = место в каталоге';
            placeInput.value = defaults.place || '';

            var scanLabel = makeEl('label', 'scmc-modal-check');
            var scanInput = document.createElement('input');
            scanInput.type = 'checkbox';
            scanLabel.appendChild(scanInput);
            scanLabel.appendChild(document.createTextNode(' Scan OFF'));

            var noteInput = document.createElement('textarea');
            noteInput.rows = 3;
            noteInput.placeholder = 'Заметка, если нужна';

            form.appendChild(makeField('Страница', pageInput));
            form.appendChild(makeField('Название', titleInput));
            form.appendChild(makeField('Раздел *', sectionSelect));
            form.appendChild(makeField('Уровень *', levelSelect));
            form.appendChild(makeField('Статус *', statusSelect));
            form.appendChild(makeField('Место', placeInput));
            form.appendChild(scanLabel);
            form.appendChild(makeField('Заметка', noteInput));

            actions.appendChild(cancelButton);
            actions.appendChild(submitButton);

            modal.appendChild(title);
            modal.appendChild(subtitle);
            modal.appendChild(form);
            modal.appendChild(error);
            modal.appendChild(actions);
            backdrop.appendChild(modal);
            document.body.appendChild(backdrop);

            cancelButton.addEventListener('click', closeModal);
            backdrop.addEventListener('click', function (event) {
                if (event.target === backdrop) closeModal();
            });

            submitButton.addEventListener('click', function () {
                var values = {
                    page: normalizeTitle(pageInput.value),
                    title: normalizeTitle(titleInput.value) || titleFromPage(pageInput.value),
                    section: normalizeTitle(sectionSelect.value),
                    level: levelSelect.value,
                    status: statusSelect.value,
                    place: placeInput.value,
                    scan: scanInput.checked ? 'stop' : '',
                    note: normalizeTitle(noteInput.value)
                };

                if (!values.section) {
                    showModalError(modal, 'Выберите раздел.');
                    return;
                }

                if (!values.level) {
                    showModalError(modal, 'Выберите уровень.');
                    return;
                }

                if (!values.status) {
                    showModalError(modal, 'Выберите статус.');
                    return;
                }

                showModalError(modal, '');
                submitButton.disabled = true;
                cancelButton.disabled = true;
                submitButton.textContent = 'Сохраняю...';

                addCandidateToCatalog(values).then(function () {
                    closeModal();

                    if (button) {
                        button.textContent = '✓ Добавлено';
                        button.disabled = true;
                        button.classList.add('is-added');
                    }
                }).catch(function (err) {
                    submitButton.disabled = false;
                    cancelButton.disabled = false;
                    submitButton.textContent = 'Добавить';
                    showModalError(modal, err && err.message ? err.message : 'Не удалось добавить страницу. Проверь права редактора.');
                });
            });
        }

        function rebuildLatestCatalogIndexes() {
            if (!latestCatalogData || !latestCatalogData.catalogRows) return;

            latestCatalogData.rowsByKey = latestCatalogData.rowsByKey || {};

            latestCatalogData.catalogRows.forEach(function (rowData, index) {
                rowData.position = index + 1;

                latestCatalogData.rowsByKey[titleKey(rowData.page)] = rowData;

                if (rowData.finalTitle) {
                    latestCatalogData.rowsByKey[titleKey(rowData.finalTitle)] = rowData;
                }
            });
        }

        function addCandidateToCatalog(values) {
            var api = getApi();

            return getCatalogSource(api).then(function (source) {
                var rows = getCatalogRowsFromSource(source);

                if (!rows.length) {
                    throw new Error('Не нашёл строки каталога в коде страницы.');
                }

                var pageKey = titleKey(values.page);
                var duplicate = rows.some(function (row) {
                    return titleKey(row.data.page) === pageKey;
                });

                if (duplicate) {
                    throw new Error('Эта страница уже есть в каталоге.');
                }

                var insertIndex = normalizePlaceInput(values.place, rows.length);
                var newLine = buildCatalogRowLine(values);
                var lines = rows.map(function (row) {
                    return row.full;
                });

                lines.splice(insertIndex, 0, newLine);

                var first = rows[0];
                var last = rows[rows.length - 1];
                var newText = source.slice(0, first.start) + lines.join('\n') + source.slice(last.end);

                return saveCatalogSource(api, newText).then(function () {
                    var rowData = {
                        page: values.page,
                        title: values.title,
                        section: values.section,
                        level: normalizeLevel(values.level),
                        position: insertIndex + 1,
                        scan: values.scan,
                        status: values.status,
                        finalTitle: values.page
                    };

                    if (latestCatalogData) {
                        latestCatalogData.catalogRows.splice(insertIndex, 0, rowData);
                        latestCatalogData.knownPages[titleKey(values.page)] = values.page;
                        latestCatalogData.rowsByKey[titleKey(values.page)] = rowData;

                        if (values.scan === 'stop') {
                            latestCatalogData.stopPages[titleKey(values.page)] = true;
                        }

                        if (latestCatalogData.sections.indexOf(values.section) === -1) {
                            latestCatalogData.sections.push(values.section);
                        }

                        rebuildLatestCatalogIndexes();
                    }
                });
            });
        }


        function isExcludedTitle(title) {
            var clean = normalizeTitle(title);

            if (!clean) return true;

            if (clean.indexOf('#') !== -1) {
                clean = normalizeTitle(clean.split('#')[0]);
            }

            if (extraExcludedTitles.some(function (excluded) {
                return titleKey(excluded) === titleKey(clean);
            })) {
                return true;
            }

            return EXCLUDED_PREFIXES.some(function (prefix) {
                return clean.indexOf(prefix) === 0;
            });
        }

        function isValidLink(link) {
            if (!link || !link.title) return false;
            if (typeof link.ns === 'number' && link.ns !== 0) return false;
            if (isExcludedTitle(link.title)) return false;
            return true;
        }

        function getPageLinks(api, requestedTitle) {
            var links = [];
            var redirects = [];
            var normalizedRequested = normalizeTitle(requestedTitle);

            function request(plcontinue) {
                var params = {
                    action: 'query',
                    prop: 'links',
                    titles: normalizedRequested,
                    pllimit: 'max',
                    redirects: 1,
                    formatversion: 2
                };

                if (plcontinue) {
                    params.plcontinue = plcontinue;
                }

                return api.get(params).then(function (data) {
                    if (data.query && data.query.redirects) {
                        data.query.redirects.forEach(function (redirect) {
                            redirects.push({
                                from: normalizeTitle(redirect.from),
                                to: normalizeTitle(redirect.to)
                            });
                        });
                    }

                    var pages = data.query && data.query.pages ? data.query.pages : [];
                    var page = pages[0];

                    if (!page) {
                        return {
                            requestedTitle: normalizedRequested,
                            finalTitle: normalizedRequested,
                            exists: false,
                            links: [],
                            redirects: redirects
                        };
                    }

                    var finalTitle = normalizeTitle(page.title || normalizedRequested);

                    if (page.missing !== undefined) {
                        return {
                            requestedTitle: normalizedRequested,
                            finalTitle: finalTitle,
                            exists: false,
                            links: [],
                            redirects: redirects
                        };
                    }

                    if (typeof page.ns === 'number' && page.ns !== 0) {
                        return {
                            requestedTitle: normalizedRequested,
                            finalTitle: finalTitle,
                            exists: true,
                            links: [],
                            redirects: redirects
                        };
                    }

                    if (page.links) {
                        page.links.forEach(function (link) {
                            if (isValidLink(link)) {
                                links.push(normalizeTitle(link.title));
                            }
                        });
                    }

                    if (data.continue && data.continue.plcontinue) {
                        return request(data.continue.plcontinue);
                    }

                    return {
                        requestedTitle: normalizedRequested,
                        finalTitle: finalTitle,
                        exists: true,
                        links: uniqueSorted(links),
                        redirects: redirects
                    };
                });
            }

            return request();
        }

        var header = makeEl('div', 'scmc-scan-header');
        var title = makeEl('div', 'scmc-scan-title', 'Сканер ссылок Marine Corps');
        var subtitle = makeEl(
            'div',
            'scmc-scan-subtitle',
            'Старт: ' + rootTitle + ' · каталог: ' + catalogTitle + ' · глубина: ' + maxDepth + ' · лимит страниц: ' + maxPages
        );

        header.appendChild(title);
        header.appendChild(subtitle);

        var controls = makeEl('div', 'scmc-scan-controls');
        var scanBtn = makeButton('Сканировать ссылки');
        var clearBtn = makeButton('Очистить', 'scmc-scan-btn scmc-scan-btn-secondary');

        controls.appendChild(scanBtn);
        controls.appendChild(clearBtn);

        var statusBox = makeEl('div', 'scmc-scan-status', 'Ожидает запуска');
        var statsBox = makeEl('div', 'scmc-scan-stats');

        var resultWrap = makeEl('div', 'scmc-scan-result');
        var treeBox = makeEl('div', 'scmc-scan-box');
        var listBox = makeEl('div', 'scmc-scan-box');
        var candidatesBox = makeEl('div', 'scmc-scan-box scmc-scan-box-wide');
        var redirectBox = makeEl('div', 'scmc-scan-box scmc-scan-box-wide');

        resultWrap.appendChild(treeBox);
        resultWrap.appendChild(listBox);
        resultWrap.appendChild(candidatesBox);
        resultWrap.appendChild(redirectBox);

        container.innerHTML = '';
        container.appendChild(header);
        container.appendChild(controls);
        container.appendChild(statusBox);
        container.appendChild(statsBox);
        container.appendChild(resultWrap);

        function setStatus(text, mode) {
            statusBox.textContent = text;
            statusBox.setAttribute('data-mode', mode || '');
        }

        function clearResults() {
            treeBox.innerHTML = '';
            listBox.innerHTML = '';
            candidatesBox.innerHTML = '';
            redirectBox.innerHTML = '';
            statsBox.innerHTML = '';
            setStatus('Ожидает запуска', '');
        }

        function renderTree(root, childrenMap, pageInfo, firstParent, alsoLinkedFrom) {
            treeBox.innerHTML = '';
            treeBox.appendChild(makeEl('div', 'scmc-scan-box-title', 'Дерево известных страниц'));

            function makeNode(title, path) {
                var key = titleKey(title);
                var info = pageInfo[key];

                var node = makeEl('div', 'scmc-scan-node');
                var line = makeEl('div', 'scmc-scan-node-line');

                var link = document.createElement('a');
                link.href = titleToUrl(title);
                link.textContent = title;

                line.appendChild(link);

                if (info) {
                    line.appendChild(makeEl('span', 'scmc-scan-depth', 'ур. ' + info.depth));

                    if (info.requestedTitle && titleKey(info.requestedTitle) !== titleKey(info.finalTitle)) {
                        line.appendChild(makeEl('span', 'scmc-scan-redirect-mini', info.requestedTitle + ' → ' + info.finalTitle));
                    }

                    if (info.candidate) {
                        line.appendChild(makeEl('span', 'scmc-scan-candidate', 'кандидат'));
                    }

                    if (info.catalogStop) {
                        line.appendChild(makeEl('span', 'scmc-scan-stopped', 'остановлено каталогом'));
                    }

                    if (info.depthStop) {
                        line.appendChild(makeEl('span', 'scmc-scan-stopped', 'предел глубины'));
                    }

                    if (info.exists === false) {
                        line.appendChild(makeEl('span', 'scmc-scan-missing', 'нет страницы'));
                    }
                }

                node.appendChild(line);

                if (path[key]) {
                    node.appendChild(makeEl('div', 'scmc-scan-loop', '↳ уже встречалась выше'));
                    return node;
                }

                var extraLinks = alsoLinkedFrom[key] || [];
                if (extraLinks.length) {
                    node.appendChild(makeEl('div', 'scmc-scan-also', 'Ещё ссылки из: ' + extraLinks.join(', ')));
                }

                var nextPath = Object.assign({}, path);
                nextPath[key] = true;

                var children = childrenMap[key] || [];

                if (children.length) {
                    var childrenWrap = makeEl('div', 'scmc-scan-children');

                    children.forEach(function (childTitle) {
                        var childKey = titleKey(childTitle);

                        if (firstParent[childKey] && titleKey(firstParent[childKey]) !== key) {
                            var refNode = makeEl('div', 'scmc-scan-node');
                            var refLine = makeEl('div', 'scmc-scan-node-line scmc-scan-node-ref');

                            var refLink = document.createElement('a');
                            refLink.href = titleToUrl(childTitle);
                            refLink.textContent = childTitle;

                            refLine.appendChild(refLink);
                            refLine.appendChild(makeEl('span', 'scmc-scan-ref', 'уже найдено в: ' + firstParent[childKey]));
                            refNode.appendChild(refLine);
                            childrenWrap.appendChild(refNode);
                            return;
                        }

                        childrenWrap.appendChild(makeNode(childTitle, nextPath));
                    });

                    node.appendChild(childrenWrap);
                }

                return node;
            }

            treeBox.appendChild(makeNode(root, {}));
        }

        function renderKnownList(pageInfo) {
            listBox.innerHTML = '';
            listBox.appendChild(makeEl('div', 'scmc-scan-box-title', 'Известные страницы из каталога'));

            var pages = Object.keys(pageInfo)
                .map(function (key) {
                    return pageInfo[key];
                })
                .filter(function (info) {
                    return !info.candidate;
                })
                .sort(function (a, b) {
                    if (a.depth !== b.depth) return a.depth - b.depth;
                    return a.finalTitle.localeCompare(b.finalTitle, 'ru');
                });

            if (!pages.length) {
                listBox.appendChild(makeEl('div', 'scmc-scan-empty', 'Пока пусто.'));
                return;
            }

            var list = makeEl('ol', 'scmc-scan-page-list');

            pages.forEach(function (info) {
                var li = document.createElement('li');

                var link = document.createElement('a');
                link.href = titleToUrl(info.finalTitle);
                link.textContent = info.finalTitle;

                li.appendChild(link);
                li.appendChild(makeEl('span', 'scmc-scan-depth', 'ур. ' + info.depth));

                if (info.requestedTitle && titleKey(info.requestedTitle) !== titleKey(info.finalTitle)) {
                    li.appendChild(makeEl('span', 'scmc-scan-redirect-mini', 'найдено как: ' + info.requestedTitle));
                }

                if (info.catalogStop) {
                    li.appendChild(makeEl('span', 'scmc-scan-stopped', 'остановлено каталогом'));
                }

                if (info.depthStop) {
                    li.appendChild(makeEl('span', 'scmc-scan-stopped', 'предел глубины'));
                }

                list.appendChild(li);
            });

            listBox.appendChild(list);
        }

        function renderCandidates(candidates) {
            candidatesBox.innerHTML = '';
            candidatesBox.appendChild(makeEl('div', 'scmc-scan-box-title', 'Кандидаты: ссылки не из каталога'));

            var pages = Object.keys(candidates)
                .map(function (key) {
                    return candidates[key];
                })
                .sort(function (a, b) {
                    if (a.depth !== b.depth) return a.depth - b.depth;
                    return a.finalTitle.localeCompare(b.finalTitle, 'ru');
                });

            if (!pages.length) {
                candidatesBox.appendChild(makeEl('div', 'scmc-scan-empty', 'Новых кандидатов не найдено.'));
                return;
            }

            var list = makeEl('ol', 'scmc-scan-page-list');

            pages.forEach(function (info) {
                var li = document.createElement('li');
                var link = document.createElement('a');
                var addButton = makeButton('+ В каталог', 'scmc-scan-add-btn');

                link.href = titleToUrl(info.finalTitle);
                link.textContent = info.finalTitle;

                addButton.addEventListener('click', function () {
                    openAddCandidateModal(info, addButton);
                });

                li.appendChild(link);
                li.appendChild(makeEl('span', 'scmc-scan-depth', 'ур. ' + info.depth));
                li.appendChild(makeEl('span', 'scmc-scan-candidate', 'не сканировалась'));

				if (info.from) {
					  var fromRef = makeEl('span', 'scmc-scan-ref');
					  
					  fromRef.appendChild(document.createTextNode('найдена из: '));
					  fromRef.appendChild(makePageLink(info.from, info.from, 'scmc-scan-ref-link'));
					  fromRef.appendChild(document.createTextNode(' · '));
					  fromRef.appendChild(makeEditLink(info.from, 'код', 'scmc-scan-ref-link scmc-scan-ref-edit'));
					  
					  li.appendChild(fromRef);
				}

                if (info.requestedTitle && titleKey(info.requestedTitle) !== titleKey(info.finalTitle)) {
                    li.appendChild(makeEl('span', 'scmc-scan-redirect-mini', info.requestedTitle + ' → ' + info.finalTitle));
                }

                if (info.exists === false) {
                    li.appendChild(makeEl('span', 'scmc-scan-missing', 'нет страницы'));
                }

                li.appendChild(addButton);
                list.appendChild(li);
            });

            candidatesBox.appendChild(list);
        }

        function renderRedirects(redirectsFound) {
            redirectBox.innerHTML = '';
            redirectBox.appendChild(makeEl('div', 'scmc-scan-box-title', 'Редиректы'));

            var keys = Object.keys(redirectsFound).sort(function (a, b) {
                return a.localeCompare(b, 'ru');
            });

            if (!keys.length) {
                redirectBox.appendChild(makeEl('div', 'scmc-scan-empty', 'Редиректы не найдены.'));
                return;
            }

            var list = makeEl('ol', 'scmc-scan-page-list');

            keys.forEach(function (from) {
                var to = redirectsFound[from];
                var li = document.createElement('li');

                var fromLink = document.createElement('a');
                fromLink.href = titleToUrl(from);
                fromLink.textContent = from;

                var toLink = document.createElement('a');
                toLink.href = titleToUrl(to);
                toLink.textContent = to;

                li.appendChild(fromLink);
                li.appendChild(document.createTextNode(' → '));
                li.appendChild(toLink);

                list.appendChild(li);
            });

            redirectBox.appendChild(list);
        }

        function scan() {
            clearResults();

            var api = getApi();

            scanBtn.disabled = true;
            clearBtn.disabled = true;

            setStatus('Читаю каталог: ' + catalogTitle, 'scanning');

            loadCatalog(api, catalogTitle).then(function (catalog) {
                latestCatalogData = catalog;

                var knownPages = catalog.knownPages;
                var stopPages = catalog.stopPages;
                var redirectsFound = Object.assign({}, catalog.redirects);

                var queue = [{
                    requestedTitle: rootTitle,
                    depth: 0,
                    parent: null
                }];

                var scanned = {};
                var queued = {};
                var pageInfo = {};
                var candidates = {};
                var childrenMap = {};
                var firstParent = {};
                var alsoLinkedFrom = {};
                var stoppedByLimit = false;

                queued[titleKey(rootTitle)] = true;

                function addRedirects(redirects) {
                    redirects.forEach(function (redirect) {
                        if (redirect.from && redirect.to) {
                            redirectsFound[redirect.from] = redirect.to;
                        }
                    });
                }

                function addAlsoLinked(childTitle, parentTitle) {
                    var childKey = titleKey(childTitle);

                    if (!alsoLinkedFrom[childKey]) {
                        alsoLinkedFrom[childKey] = [];
                    }

                    if (alsoLinkedFrom[childKey].indexOf(parentTitle) === -1) {
                        alsoLinkedFrom[childKey].push(parentTitle);
                    }
                }

                function upsertPageInfo(finalTitle, requestedTitle, depth, exists, options) {
                    var finalKey = titleKey(finalTitle);
                    var current = pageInfo[finalKey];

                    if (!current) {
                        current = {
                            requestedTitle: requestedTitle || finalTitle,
                            finalTitle: finalTitle,
                            depth: depth,
                            exists: exists
                        };

                        pageInfo[finalKey] = current;
                    }

                    if (depth < current.depth) {
                        current.depth = depth;
                    }

                    if (requestedTitle && titleKey(requestedTitle) !== titleKey(finalTitle)) {
                        current.requestedTitle = requestedTitle;
                    }

                    if (exists === false) {
                        current.exists = false;
                    }

                    if (options) {
                        Object.keys(options).forEach(function (key) {
                            current[key] = options[key];
                        });
                    }

                    return current;
                }

                function step() {
                    if (!queue.length) {
                        finish();
                        return;
                    }

                    if (Object.keys(scanned).length >= maxPages) {
                        stoppedByLimit = true;
                        finish();
                        return;
                    }

                    var item = queue.shift();
                    var requestedTitle = normalizeTitle(item.requestedTitle);
                    var requestedKey = titleKey(requestedTitle);

                    if (scanned[requestedKey]) {
                        step();
                        return;
                    }

                    scanned[requestedKey] = true;

                    setStatus('Сканирую: ' + requestedTitle + ' · уровень ' + item.depth, 'scanning');

                    getPageLinks(api, requestedTitle).then(function (data) {
                        addRedirects(data.redirects);

                        var finalTitle = normalizeTitle(data.finalTitle || requestedTitle);
                        var finalKey = titleKey(finalTitle);
                        var isRoot = item.depth === 0;
                        var isKnown = !!knownPages[finalKey] || !!knownPages[requestedKey] || isRoot;
                        var isCatalogStop = !isRoot && (!!stopPages[finalKey] || !!stopPages[requestedKey]);

                        upsertPageInfo(finalTitle, requestedTitle, item.depth, data.exists, {
                            catalogStop: isCatalogStop
                        });

                        if (!isKnown) {
                            candidates[finalKey] = {
                                requestedTitle: requestedTitle,
                                finalTitle: finalTitle,
                                depth: item.depth,
                                exists: data.exists,
                                from: item.parent || '',
                                candidate: true
                            };

                            upsertPageInfo(finalTitle, requestedTitle, item.depth, data.exists, {
                                candidate: true
                            });

                            setTimeout(step, 80);
                            return;
                        }

                        if (isCatalogStop) {
                            childrenMap[finalKey] = [];
                            setTimeout(step, 80);
                            return;
                        }

                        if (item.depth >= maxDepth) {
                            pageInfo[finalKey].depthStop = true;
                            childrenMap[finalKey] = [];
                            setTimeout(step, 80);
                            return;
                        }

                        resolveTitles(api, data.links).then(function (resolvedLinks) {
                            Object.keys(resolvedLinks.redirects).forEach(function (from) {
                                redirectsFound[from] = resolvedLinks.redirects[from];
                            });

                            var children = [];

                            data.links.forEach(function (originalLinkTitle) {
                                var originalKey = titleKey(originalLinkTitle);
                                var resolved = resolvedLinks.items[originalKey];

                                var childRequested = resolved ? resolved.requestedTitle : originalLinkTitle;
                                var childFinal = resolved ? resolved.finalTitle : originalLinkTitle;
                                var childExists = resolved ? resolved.exists : null;
                                var childKey = titleKey(childFinal);

                                if (isExcludedTitle(childRequested) || isExcludedTitle(childFinal)) {
                                    return;
                                }

                                var childKnown = !!knownPages[childKey] || !!knownPages[originalKey];
                                var childStop = !!stopPages[childKey] || !!stopPages[originalKey];

                                children.push(childFinal);

                                if (!firstParent[childKey]) {
                                    firstParent[childKey] = finalTitle;
                                } else if (titleKey(firstParent[childKey]) !== finalKey) {
                                    addAlsoLinked(childFinal, finalTitle);
                                }

                                if (childKnown) {
                                    upsertPageInfo(childFinal, childRequested, item.depth + 1, childExists, {
                                        catalogStop: childStop
                                    });

                                    if (!childStop && !queued[childKey]) {
                                        queued[childKey] = true;

                                        queue.push({
                                            requestedTitle: childFinal,
                                            depth: item.depth + 1,
                                            parent: finalTitle
                                        });
                                    }
                                } else {
                                    candidates[childKey] = {
                                        requestedTitle: childRequested,
                                        finalTitle: childFinal,
                                        depth: item.depth + 1,
                                        exists: childExists,
                                        from: finalTitle,
                                        candidate: true
                                    };

                                    upsertPageInfo(childFinal, childRequested, item.depth + 1, childExists, {
                                        candidate: true
                                    });
                                }
                            });

                            childrenMap[finalKey] = uniqueSorted(children);

                            statsBox.textContent =
                                'Каталог: ' + Object.keys(knownPages).length +
                                ' · Просканировано: ' + Object.keys(scanned).length +
                                ' · В очереди: ' + queue.length +
                                ' · Известных найдено: ' + Object.keys(pageInfo).length +
                                ' · Кандидатов: ' + Object.keys(candidates).length +
                                ' · Редиректов: ' + Object.keys(redirectsFound).length;

                            setTimeout(step, 100);
                        });
                    }).catch(function (error) {
                        console.error(error);

                        setStatus('Ошибка при сканировании: ' + requestedTitle, 'error');

                        scanBtn.disabled = false;
                        clearBtn.disabled = false;
                    });
                }

                function finish() {
                    var rootFinal = rootTitle;

                    Object.keys(pageInfo).some(function (key) {
                        var info = pageInfo[key];

                        if (titleKey(info.requestedTitle) === titleKey(rootTitle) || titleKey(info.finalTitle) === titleKey(rootTitle)) {
                            rootFinal = info.finalTitle;
                            return true;
                        }

                        return false;
                    });

                    renderTree(rootFinal, childrenMap, pageInfo, firstParent, alsoLinkedFrom);
                    renderKnownList(pageInfo);
                    renderCandidates(candidates);
                    renderRedirects(redirectsFound);

                    var finalText =
                        'Готово. Каталог прочитан. Просканировано: ' + Object.keys(scanned).length +
                        '. Кандидатов: ' + Object.keys(candidates).length +
                        '. Редиректов: ' + Object.keys(redirectsFound).length + '.';

                    if (stoppedByLimit) {
                        finalText += ' Остановлено по лимиту страниц.';
                    }

                    statsBox.textContent = finalText;
                    setStatus('Готово', 'done');

                    scanBtn.disabled = false;
                    clearBtn.disabled = false;
                }

                step();
            }).catch(function (error) {
                console.error(error);

                setStatus('Не удалось прочитать каталог: ' + catalogTitle, 'error');

                scanBtn.disabled = false;
                clearBtn.disabled = false;
            });
        }

        scanBtn.addEventListener('click', scan);
        clearBtn.addEventListener('click', clearResults);
    }

    buildScanner(scannerRoot);
})();