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

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

Страница интерфейса MediaWiki
Нет описания правки
Нет описания правки
 
(не показаны 3 промежуточные версии этой же участницы)
Строка 37: Строка 37:
         return mw.util.getUrl(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) {
     function makeEl(tag, className, text) {
Строка 296: Строка 327:
                     section: section,
                     section: section,
                     level: level,
                     level: level,
                    position: catalogRows.length + 1,
                     scan: isStopCatalogRow(tag) ? 'stop' : '',
                     scan: isStopCatalogRow(tag) ? 'stop' : '',
                     status: normalizeTitle(extractAttr(tag, ['data-status', 'data_status']))
                     status: normalizeTitle(extractAttr(tag, ['data-status', 'data_status']))
Строка 446: Строка 478:
                         title: title,
                         title: title,
                         section: section,
                         section: section,
                         level: level
                         level: level,
                        position: rows.length + 1
                     }
                     }
                 });
                 });
Строка 481: Строка 514:
             var result = {
             var result = {
                 section: '',
                 section: '',
                 level: ''
                 level: '',
                place: ''
             };
             };


Строка 496: Строка 530:
             result.section = parent.section || '';
             result.section = parent.section || '';
             result.level = String(Math.min(5, normalizeLevel(parent.level + 1)));
             result.level = String(Math.min(5, normalizeLevel(parent.level + 1)));
            if (parent.position) {
                result.place = String(parent.position + 1);
            }


             return result;
             return result;
Строка 567: Строка 605:
             placeInput.inputMode = 'numeric';
             placeInput.inputMode = 'numeric';
             placeInput.placeholder = 'Пусто = в конец, число = место в каталоге';
             placeInput.placeholder = 'Пусто = в конец, число = место в каталоге';
            placeInput.value = defaults.place || '';


             var scanLabel = makeEl('label', 'scmc-modal-check');
             var scanLabel = makeEl('label', 'scmc-modal-check');
Строка 649: Строка 688:
                     showModalError(modal, err && err.message ? err.message : 'Не удалось добавить страницу. Проверь права редактора.');
                     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;
                }
             });
             });
         }
         }
Строка 689: Строка 744:
                         section: values.section,
                         section: values.section,
                         level: normalizeLevel(values.level),
                         level: normalizeLevel(values.level),
                        position: insertIndex + 1,
                         scan: values.scan,
                         scan: values.scan,
                         status: values.status,
                         status: values.status,
Строка 695: Строка 751:


                     if (latestCatalogData) {
                     if (latestCatalogData) {
                         latestCatalogData.catalogRows.push(rowData);
                         latestCatalogData.catalogRows.splice(insertIndex, 0, rowData);
                         latestCatalogData.knownPages[titleKey(values.page)] = values.page;
                         latestCatalogData.knownPages[titleKey(values.page)] = values.page;
                         latestCatalogData.rowsByKey[titleKey(values.page)] = rowData;
                         latestCatalogData.rowsByKey[titleKey(values.page)] = rowData;
Строка 706: Строка 762:
                             latestCatalogData.sections.push(values.section);
                             latestCatalogData.sections.push(values.section);
                         }
                         }
                        rebuildLatestCatalogIndexes();
                     }
                     }
                 });
                 });
Строка 1059: Строка 1117:
                 li.appendChild(makeEl('span', 'scmc-scan-candidate', 'не сканировалась'));
                 li.appendChild(makeEl('span', 'scmc-scan-candidate', 'не сканировалась'));


                if (info.from) {
if (info.from) {
                    li.appendChild(makeEl('span', 'scmc-scan-ref', 'найдена из: ' + 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)) {
                 if (info.requestedTitle && titleKey(info.requestedTitle) !== titleKey(info.finalTitle)) {

Текущая версия от 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);
})();