MediaWiki:Gadget-wstaw-link-interwiki.js

Z Wikipedii, wolnej encyklopedii

Uwaga: aby zobaczyć zmiany po opublikowaniu, może zajść potrzeba wyczyszczenia pamięci podręcznej przeglądarki.

  • Firefox / Safari: Przytrzymaj Shift podczas klikania Odśwież bieżącą stronę, lub naciśnij klawisze Ctrl+F5, lub Ctrl+R (⌘-R na komputerze Mac)
  • Google Chrome: Naciśnij Ctrl-Shift-R (⌘-Shift-R na komputerze Mac)
  • Internet Explorer / Edge: Przytrzymaj Ctrl, jednocześnie klikając Odśwież, lub naciśnij klawisze Ctrl+F5
  • Opera: Naciśnij klawisze Ctrl+F5.
//@ts-check
/**
 * @author [[w:pl:User:Msz2001]]
 * 
 * Skrypt ułatwia zamianę czerwonych linków na szablon {{link-interwiki}}.
 * 
 * <nowiki>
 */
$(function(){
    var LINK_TARGET_INDEX = 'data-target-index';
    var LINK_FROM_TEMPLATE = 'data-from-template';

    var TRIGGER_TEXT = '{{link-interwiki}}';
    var TRIGGER_TOOLTIP = 'Zamień czerwone linki na szablon {{link-interwiki}}';
    var SAVE_BUTTON = 'Zapisz';
    var DISCARD_BUTTON = 'Odrzuć';
    var SAVING_TEXT = 'Zapisywanie...';
    var COUNTER_TEXT = 'Zamieniono linków: $1 / $2';
    var LINK_TITLE = 'Zamień na {{link-interwiki}} powiązany z Wikidanymi';
    var QID_POPUP_TITLE = 'Wiązanie elementu Wikidanych';
    var PROMPT_TEXT = 'Podaj identyfikator elementu w Wikidanych:';
    var PROMPT_PLACEHOLDER = 'np. Q123456';
    var INVALID_QID = 'Nie jest to poprawny identyfikator. Po kliknięciu „Wstaw” zostanie umieszczony zwykły link do Wikipedii';
    var ACCEPT_BTN = 'Zatwierdź';
    var NEXT_BTN = 'Dalej';
    var BACK_BTN = 'Wstecz';
    var CANCEL_BTN = 'Anuluj';

    var SUGGESTION_HEADER = 'Sugestie';
    var SUGGESTION_EXT_TITLE = 'Zobacz ten element w Wikidanych';
    var SUGGESTION_LOADING = 'Wczytywanie...';
    var SUGGESTION_NO_RESULTS = 'Brak sugestii';
    var SUGGESTION_NODESC = 'brak opisu';
    var SUGGESTION_ERROR = 'Wystąpił błąd: $1';

    var CONFIRM_WIKITEXT_TEXT = '<div>Czy szablon został wstawiony w poprawnym miejscu? W rzadkich przypadkach skrypt może błędnie zlokalizować link do zamiany.<pre>$1</pre></div>';
    var DISCARD_CONFIRM_TEXT = 'Czy na pewno chcesz odrzucić wszystkie zmiany?';
    var ERROR_TEXT = 'Nie udało się zlokalizować linku w wikikodzie. Dokonaj zmian ręcznie.';
    var EDIT_SUMMARY = 'Zamiana $1 czerwonych linków na szablon {{[[Szablon:Link-interwiki|Link-interwiki]]}}';

    var ERROR_TITLE = 'Wystąpił błąd';

    var API_CONFIG = {
        parameters: {
            format: 'json',
            formatversion: 2,
            errorformat: 'html',
            errorlang: mw.config.get('wgUserLanguage'),
            errorsuselocal: true
        }
    };

    // These store the wikitext representing the page after any modifications
    // The `actualWikitext` will actually be saved to the page
    // The `sanitizedWikitext` is the original wikitext with some strings escaped
    // Both variables should always have the same length so that index-based searches work
    var actualWikitext;
    var sanitizedWikitext;

    /** The revision id of the wikitext being edited (used for edit conflicts discovery) */
    var wikitextRevision;

    /** A promise used for waiting for fetching the wikitext */
    var wikitextPromise;

    /** Number of changes already made */
    var numberOfChanges = 0;

    /** Total number of red links present on the page */
    var totalRedLinks = 0;

    /** The element with save and discard buttons */
    var saveBox;
    var counterBox;

    /** Links that have been used but may need to be restored upon discard */
    var hiddenLinks = [];

    /** Prevents from closing the page if there are unsaved changes */
    var windowCloseConfirm;

    /**
     * Traverse the DOM tree and find all redlinks.
     * @returns {HTMLAnchorElement[]} An array of redlinks
     */
    function getRedlinks() {
        var navboxLinks = document.querySelectorAll('.mw-parser-output .NavFrame a.new, .mw-parser-output .navbox a.new');
        navboxLinks.forEach(function(link) {
            link.setAttribute(LINK_FROM_TEMPLATE, 'true');
        });

        /** @type {NodeListOf<HTMLAnchorElement>} */
        var domLinks = document.querySelectorAll('.mw-parser-output a.new:not([' + LINK_FROM_TEMPLATE + '])');
        var redlinks = [];
        var targets = Object.create(null);
        for (var i = 0; i < domLinks.length; i++) {
            var link = domLinks[i];

            var nextSibling = link.nextElementSibling;
            if(nextSibling){
                // If the next sibling has one of those classes, it's generated by {{link-interwiki}}
                // Therefore skip it
                if(nextSibling.classList.contains('link-interwiki') || nextSibling.classList.contains('extiw')){
                    continue;
                }
            }

            redlinks.push(link);

            // Set the normalized target page and link index as data attributes
            var targetPage = extractPageTitle(link.href);
            targetPage = normalizePageName(targetPage);
            var targetIndex = targets[targetPage] || 0;
            targets[targetPage] = targetIndex + 1;
            link.setAttribute(LINK_TARGET_INDEX, targetIndex);
        }
        return redlinks;
    }

    /**
     * Displays a link that invokes the script on a given link.
     * @param {HTMLAnchorElement[]} redlinks An array of redlinks
     * @returns {void}
     */
    function displayLinks(redlinks) {
        redlinks.forEach(function(redlink) {
            var link = document.createElement('a');
            link.href = 'javascript:void(0)';
            link.innerHTML = '<sub class="insert-interwiki">Q</sub>';
            link.title = LINK_TITLE;
            link.addEventListener('click', function(e) {
                invoke(redlink).then(function(){
                    numberOfChanges++;
                    updateSaveBox();

                    link.style.display = 'none';
                    hiddenLinks.push(link);
                }).fail(function(error){
                    if(!error) return;
                    mw.notify(error, {autoHideSeconds: 'long', title: ERROR_TITLE, type: 'error'});
                });
                e.preventDefault();
            });

            if(redlink.parentNode){
                redlink.parentNode.insertBefore(link, redlink.nextSibling);
            }
        });
    }

    /**
     * Invokes the script and asks user for input.
     * @param {HTMLAnchorElement} link The link to invoke the script on
     * @returns {JQuery.Promise<void, JQuery | string | null>} A promise that resolves when the link has been added or failed
     */
    function invoke(link){
        var deferred = $.Deferred();

        var page = mw.config.get('wgPageName');

        // Download the wikitext if not already done
        if(!wikitextPromise){
            wikitextPromise = getPageWikitext(page).then(function(revision){
                wikitextRevision = revision.revisionId;
                actualWikitext = revision.wikitext;
                sanitizedWikitext = sanitizeWikitext(actualWikitext);
            });
            wikitextPromise.fail(function (error){
                deferred.reject(error);
            });
        }

        var localTitle = extractPageTitle(link.href);
        var targetIndex = parseInt(link.getAttribute(LINK_TARGET_INDEX) || '0');
        var linkText = link.innerText;

        mw.loader.using(['oojs-ui-core', 'oojs-ui-windows', 'oojs-ui-widgets', 'mediawiki.api', 'mediawiki.confirmCloseWindow']).then(function(){
            var promptPromise = displayQidPopup(link, function(qid){
                var isWikidata = /^Q?\d+$/i.test(qid);
                return createNewWikitext(localTitle, linkText, qid, isWikidata, targetIndex);
            }, getItemSuggestions(localTitle));

            promptPromise.done(function(data){
                actualWikitext = data.actualWikitext;
                sanitizedWikitext = data.sanitizedWikitext;
                deferred.resolve();
            }).fail(function(error){
                deferred.reject(error);
            });
        });

        return deferred.promise();
    }

    /**
     * Creates a new wikitext and asks the user if it's correct.
     * @param {string} localTitle The human-friendly local page name
     * @param {string} linkText The visible link text
     * @param {string} linkTarget The target page name
     * @param {boolean} isWikidata Whether the link is to Wikidata (`false` creates ordinary local link)
     * @param {number} targetIndex The index of the link to the target page
     * @returns {{actualWikitext: string, sanitizedWikitext: string, excerpt: string} | null} Data about the new wikitext or null if the link could not be found
     */
    function createNewWikitext(localTitle, linkText, linkTarget, isWikidata, targetIndex){
        var template = prepareTemplate(localTitle, linkText, linkTarget, isWikidata);
        var replaced = replaceLinkWithText(actualWikitext, sanitizedWikitext, localTitle, targetIndex, template);

        if(!replaced) return null;

        var excerptForUser = makeExcerpt(
            replaced.newWikitext, replaced.indexFrom, replaced.indexTo, 30);
        excerptForUser = excerptForUser.replace(/</g, '&lt;');

        return {
            actualWikitext: replaced.newWikitext,
            sanitizedWikitext: replaced.newSanitizedWikitext,
            excerpt: excerptForUser
        }
    }

    /**
     * Returns a promise that resolves to the wikitext of the page.
     * @param {string} page The page name
     * @returns {JQuery.Promise<{wikitext: string, revisionId: number}, JQuery>} The page's wikitext
     */
    function getPageWikitext(page){
        var deferred = $.Deferred();

        var api = new mw.Api(API_CONFIG);
        api.get({
            action: 'query',
            prop: 'revisions',
            titles: page,
            rvprop: ['content', 'ids'],
            rvslots: 'main'
        }).then(function(data){
            var page = data.query.pages[0];
            var revision = page.revisions[0];
            var slot = revision.slots.main;

            var wikitext = slot.content;
            var revid = revision.revid;

            deferred.resolve({
                wikitext: wikitext,
                revisionId: revid
            });
        }).fail(function(code, result){
            deferred.reject(api.getErrorMessage(result));
        });

        return deferred.promise();
    }

    /**
     * Extracts the page title from link href.
     * @param {string} href The link href
     * @returns {string} The page title
     */
    function extractPageTitle(href){
        var match;
        if(href.indexOf('/wiki/') !== -1){
            match = href.match(/\/wiki\/([^?]+)/);
        }else{
            match = href.match(/[?&]title=([^&]+)(&|$)/);
        }
        if(match === null) return '';

        var title = match[1]
        title = decodeURIComponent(title);
        title = title.replace(/_/g, ' ');
        return title;
    }

    /**
     * Replaces a link with the specified text.
     * @param {string} actualWikitext The actual wikitext to replace the link in
     * @param {string} sanitizedWikitext The sanitized wikitext to replace the link in
     * @param {string} targetPage The link target page
     * @param {number} linkIndex The ordinal number of the link among others with the same target
     * @param {string} textToReplace A text to be inserted in place of the link
     * @returns {LinkReplaceState | null} The modified wikitext or null if the replace failed
     */
    function replaceLinkWithText(actualWikitext, sanitizedWikitext, targetPage, linkIndex, textToReplace){
        // Link is a text in the form of [[targetPage|linkText]]trail
        // where linkText and trail are optional,
        //       targetPage cannot contain a character from set: []{}<>|
        //       linkText cannot contain a ] character
        //       trail can only be letters
        var matches = Array.from(
            sanitizedWikitext.matchAll(/\[\[([^\[\]{}<>|]+)(\|[^\]]+)?\]\]([a-zA-ZąćęłńóśźżĄĆĘŁŃÓŚŹŻ]*)/g));
        
        targetPage = normalizePageName(targetPage);
        for(var i = 0; i < matches.length; i++){
            var match = matches[i];
            var matchIndex = match.index;
            if(matchIndex === undefined) continue;

            if(normalizePageName(match[1]) === targetPage){
                if(linkIndex > 0){
                    linkIndex--;
                    continue;
                }

                if(textToReplace.length < match[0].length + 3) {
                    // 0x18 (CANCEL in ASCII) will be then stripped out
                    textToReplace = textToReplace.padEnd(match[0].length + 3, '\x18');
                }

                // Replace the link with the text
                actualWikitext = actualWikitext.substring(0, matchIndex) +
                    textToReplace + actualWikitext.substring(matchIndex + match[0].length);

                // Prepare a placeholder for sanitized wikitext: [[targetPage|linkText|<...>]]trail
                var synchronizedPlaceholder = match[0].substring(0, match[0].length - match[3].length - 2);
                synchronizedPlaceholder += '|<';
                synchronizedPlaceholder += '.'.repeat(textToReplace.length - match[0].length - 3);
                synchronizedPlaceholder += '>]]' + match[3];

                // Synchronize the sanitized wikitext
                sanitizedWikitext = sanitizedWikitext.substring(0, matchIndex) +
                    synchronizedPlaceholder + sanitizedWikitext.substring(matchIndex + match[0].length);
                return {
                    newWikitext: actualWikitext,
                    newSanitizedWikitext: sanitizedWikitext,
                    indexFrom: matchIndex,
                    indexTo: matchIndex + textToReplace.length
                };
            }
        }
        return null;
    }

    /**
     * Removes references, comments and nowiki tags from the wikitext and replaces them
     * with placeholders of the same length.
     * @param {string} wikitext The wikitext to sanitize
     */
    function sanitizeWikitext(wikitext){
        wikitext = wikitext.replace(/<nowiki *>.*?<\/nowiki *>/gi, function(match){
            return '<' + 'X'.repeat(match.length - 2) + '>';
        });
        wikitext = wikitext.replace(/<!--.*?-->/gi, function(match){
            return '<' + 'X'.repeat(match.length - 2) + '>';
        });

        // References may contain links that are displayed out-of-order - at the end of article
        // That's why we need to ignore them as well
        wikitext = wikitext.replace(/<ref[^\/>]*>.*?<\/ref *>/gi, function(match){
            return '<' + 'X'.repeat(match.length - 2) + '>';
        });
        return wikitext;
    }

    /**
     * Transforms the page name to uppercase with spaces replaced with underscores.
     * @param {string} page The page name
     */
    function normalizePageName(page){
        return page.toUpperCase().replace(/ /g, '_');
    }

    /**
     * Prepares the template for an interlanguage link.
     * @param {string} localArticle The local article name
     * @param {string} displayedText The text displayed in the link
     * @param {string} linkTarget The linked article name
     * @param {boolean} isWikidata Whether the link is to Wikidata (`false` creates ordinary local link)
     * @returns {string}
     */
    function prepareTemplate(localArticle, displayedText, linkTarget, isWikidata){
        // If local article title and displayed text differ only in first letter case,
        // use the display name as the local article title
        if(localArticle.substring(1) === displayedText.substring(1)
            && localArticle[0].toUpperCase() === displayedText[0].toUpperCase()){
                localArticle = displayedText;
        }

        if(!isWikidata){
            if(linkTarget == displayedText) return '[[' + linkTarget + ']]';
            return '[[' + linkTarget + '|' + displayedText + ']]';
        }

        // Wikidata link
        // Capitalize the Q
        linkTarget = linkTarget.toUpperCase();
        if(linkTarget[0] !== 'Q'){
            linkTarget = 'Q' + linkTarget;
        }

        var templateText = '{{link-interwiki |';

        // Just in case
        if(localArticle.indexOf('=') !== -1) templateText += '1=';
        templateText += localArticle;

        if(displayedText !== localArticle){
            templateText += ' |tekst=' + displayedText;
        }

        templateText += ' |Q=' + linkTarget + '}}';
        return templateText;
    }

    /**
     * Makes an excerpt focusing on the specified range.
     * @param {string} text The original text
     * @param {number} indexFrom The index of the first significant character
     * @param {number} indexTo The index of the last significant character
     * @param {number} margins The number of characters to include before and after the selection
     * @returns {string} The excerpt with significant text and some margin
     */
    function makeExcerpt(text, indexFrom, indexTo, margins){
        var excerptStart = Math.max(0, indexFrom - margins);
        var excerptEnd = Math.min(text.length, indexTo + margins);
        var excerpt = text.substring(excerptStart, excerptEnd);

        excerpt = excerpt.replace(/\x18/g, '');

        if(excerptStart > 0) excerpt = '...' + excerpt;
        if(excerptEnd < text.length) excerpt += '...';

        return excerpt;
    }

    /**
     * Updates the counter in the save box.
     * If necessary, the box is created.
     */
    function updateSaveBox(){
        if(!saveBox){
            saveBox = $('<div style="position:fixed; bottom:8px; right:12px; background:white; padding:8px;border:1px solid #ccc; border-radius:4px; box-shadow:0 1px 4px rgba(0, 0, 0, 0.15);"></div>');
            saveBox.appendTo(document.body);

            counterBox = $('<div style="margin-bottom:8px; text-align:center; font-size:0.9em;"></div>');
            counterBox.appendTo(saveBox);

            var buttonDiscard = new OO.ui.ButtonWidget({
                label: DISCARD_BUTTON
            });
            buttonDiscard.on('click', discard);
            saveBox.append(buttonDiscard.$element);

            var buttonSave = new OO.ui.ButtonWidget({
                label: SAVE_BUTTON,
                flags: ['primary', 'progressive']
            });
            buttonSave.on('click', function(){
                buttonDiscard.setDisabled(true);
                buttonSave.setDisabled(true);
                buttonSave.setLabel(SAVING_TEXT);
                save().then(function(){
                    location.reload();
                }).fail(function(error){
                    buttonDiscard.setDisabled(false);
                    buttonSave.setDisabled(false);
                    buttonSave.setLabel(SAVE_BUTTON);
                    mw.notify(error, {autoHideSeconds: 'long', title: ERROR_TITLE, type: 'error'});
                });
            });
            saveBox.append(buttonSave.$element);
        }
        counterBox.text(mw.format(COUNTER_TEXT, numberOfChanges, totalRedLinks));

        //@ts-ignore
        if(!windowCloseConfirm) windowCloseConfirm = mw.confirmCloseWindow();
    }

    /**
     * Saves the changes
     * @returns {JQuery.Promise}
     */
    function save(){
        var deferred = $.Deferred();

        var wikitext = actualWikitext;
        wikitext = wikitext.replace(/\x18/g, '');

        var page = mw.config.get('wgPageName');
        var api = new mw.Api(API_CONFIG);
        api.postWithEditToken({
            action: 'edit',
            title: page,
            text: wikitext,
            summary: mw.format(EDIT_SUMMARY, numberOfChanges),
            minor: true,
            bot: hasBotFlag(),
            nocreate: true,
            watchlist: 'nochange',
            baserevid: wikitextRevision
        }).then(function(){
            if(windowCloseConfirm) windowCloseConfirm.release();
            windowCloseConfirm = undefined;

            deferred.resolve();
        }).fail(function(code, result){
            deferred.reject(api.getErrorMessage(result));
        });

        return deferred.promise();
    }

    /**
     * Discards the changes and unsets all the variables.
     */
    function discard(){
        OO.ui.confirm(DISCARD_CONFIRM_TEXT).done(function(confirmed){
            if(!confirmed) return;

            wikitextPromise = undefined;
            actualWikitext = undefined;
            sanitizedWikitext = undefined;
            wikitextRevision = undefined;
            numberOfChanges = 0;
            saveBox.remove();
            saveBox = undefined;

            if(windowCloseConfirm) windowCloseConfirm.release();
            windowCloseConfirm = undefined;

            hiddenLinks.forEach(function(link){
                link.style.display = '';
            });
            hiddenLinks = [];
        });
    }

    /**
     * Asks the user to enter a QID and displays a popup with the wikitext.
     * @param {HTMLElement} anchor The anchor element to display the popup next to
     * @param {(qid: string) => ({actualWikitext: string, sanitizedWikitext: string, excerpt: string} | null)} makeWikitext A function that returns the wikitexts and excerpt for the specified QID
     * @param {JQuery.Promise<WikidataSuggestion[], string> | null} [suggestionsPromise] A promise that resolves to an array of suggestions or rejects with an error message
     * @returns {JQuery.Promise} A promise that resolves to the new wikitexts
     */
    function displayQidPopup(anchor, makeWikitext, suggestionsPromise){
        var deferred = $.Deferred();

        // Ask for the QID step
        var qidPanel = new OO.ui.PanelLayout({
            expanded: false
        });
        var fieldset = new OO.ui.FieldsetLayout({});
        qidPanel.$element.append(fieldset.$element);

        var qidInput = new OO.ui.TextInputWidget({
            placeholder: PROMPT_PLACEHOLDER
        });
        var qidLayout = new OO.ui.FieldLayout(qidInput, {
            label: PROMPT_TEXT,
            align: 'top'
        });
        fieldset.addItems([qidLayout]);
        
        var qidNextButton = new OO.ui.ButtonWidget({
            label: NEXT_BTN,
            flags: ['primary', 'progressive'],
            disabled: true
        });
        var qidCancelButton = new OO.ui.ButtonWidget({
            label: CANCEL_BTN
        });
        var qidButtonLayout = $('<div style="text-align:right; margin-top:8px"></div>');
        qidButtonLayout.append(qidCancelButton.$element);
        qidButtonLayout.append(qidNextButton.$element);
        qidPanel.$element.append(qidButtonLayout);

        // Display suggestions if available
        if(suggestionsPromise){
            var $suggestionsContainer = displaySuggestionList(
                suggestionsPromise,
                function(qId){ qidInput.setValue(qId); }
            );
            $suggestionsContainer.insertBefore(qidButtonLayout);
        }

        // Confirm the wikitext step
        var wikitextPanel = new OO.ui.PanelLayout({
            expanded: false
        });

        var promptText = $('<div></div>');
        wikitextPanel.$element.append(promptText);

        var wikitextAcceptButton = new OO.ui.ButtonWidget({
            label: ACCEPT_BTN,
            flags: ['primary', 'progressive'],
        });
        var wikitextBackButton = new OO.ui.ButtonWidget({
            label: BACK_BTN
        });
        var wikitextButtonLayout = $('<div style="text-align:right; margin-top:8px"></div>');
        wikitextButtonLayout.append(wikitextBackButton.$element);
        wikitextButtonLayout.append(wikitextAcceptButton.$element);
        wikitextPanel.$element.append(wikitextButtonLayout);

        var contentStack = new OO.ui.StackLayout({
            expanded: false,
            items: [qidPanel, wikitextPanel]
        });
        contentStack.setItem(qidPanel);

        var $popupContainer = $('body');
        var popup = new OO.ui.PopupWidget({
            $container: $popupContainer,
            $floatableContainer: $(anchor),
            $content: contentStack.$element,
            padded: true,
            width: 350,
            head: true,
            label: QID_POPUP_TITLE,
            hideCloseButton: true,
            autoFlip: true,
            classes: ['insert-link-interwiki-popup']
        });

        $popupContainer.append(popup.$element);
        popup.toggle(true);

        var newWikitext;
        qidInput.on('change', function(){
            var qid = qidInput.getValue();
            qidNextButton.setDisabled(qid.length === 0);
            if(!/^Q?\d+$/i.test(qid) && qid.length > 0){
                qidLayout.setWarnings([INVALID_QID]);
            }else{
                qidLayout.setWarnings([]);
            }
        });
        qidNextButton.on('click', function(){
            newWikitext = makeWikitext(qidInput.getValue());
            if(!newWikitext){
                return deferred.reject(ERROR_TEXT);
            }
            var message = mw.format(CONFIRM_WIKITEXT_TEXT, newWikitext.excerpt);

            promptText.empty();
            promptText.append(
                $(message)
            );
            contentStack.setItem(wikitextPanel);
        });
        qidCancelButton.on('click', function(){
            popup.toggle(false);
            deferred.reject(null);
        });
        wikitextAcceptButton.on('click', function(){
            popup.toggle(false);
            deferred.resolve(newWikitext);
        });
        wikitextBackButton.on('click', function(){
            contentStack.setItem(qidPanel);
        });

        return deferred.promise();
    }

    /**
     * Renders a list of item suggestions
     * @param {JQuery.Promise<WikidataSuggestion[], string>} suggestionsPromise The promise that resolves to the suggestions array
     * @param {(qId: string) => void} onSelect A function that is called when the user selects a suggestion (with QId as parameter)
     * @returns {JQuery<HTMLElement>}
     */
    function displaySuggestionList(suggestionsPromise, onSelect){
        var $suggestionsContainer = $('<div class="suggestions"></div>');
        $suggestionsContainer.append('<div class="header">' + SUGGESTION_HEADER + '</div>');

        var $emptyState = $('<div class="empty-state">' + SUGGESTION_LOADING + '</div>');
        $suggestionsContainer.append($emptyState);

        suggestionsPromise.then(function(suggestions){
            if(suggestions.length === 0){
                $emptyState.text(SUGGESTION_NO_RESULTS);
                return;
            }

            $emptyState.remove();

            var $suggestionsList = $('<ul></ul>');
            suggestions.forEach(function(suggestion){
                // Prepare the list item
                var $suggestionItem = $('<li></li>');
                var $mainButton = $('<button type="button"></button>');
                $('<span class="label"></span>')
                    .text(suggestion.label + ' (' + suggestion.id + ')')
                    .appendTo($mainButton);
                $('<span class="description"></span>')
                    .text(suggestion.description || SUGGESTION_NODESC)
                    .appendTo($mainButton);

                $mainButton.on('click', function(){
                    onSelect(suggestion.id);
                });
                $suggestionItem.append($mainButton);

                // Add a link to Wikidata
                var $wikidataLink = $('<a></a>');
                $wikidataLink.attr('href', 'https://www.wikidata.org/wiki/' + suggestion.id);
                $wikidataLink.attr('target', '_blank');
                $wikidataLink.attr('title', SUGGESTION_EXT_TITLE);
                $wikidataLink.append('<img src="https://upload.wikimedia.org/wikipedia/commons/6/67/OOjs_UI_icon_external-link-ltr.svg" />');
                $suggestionItem.append($wikidataLink);
                $suggestionsList.append($suggestionItem);
            });

            $suggestionsContainer.append($suggestionsList);
        }).fail(function(error){
            $emptyState.text(mw.format(SUGGESTION_ERROR, error));
        });

        return $suggestionsContainer;
    }

    /**
     * Returns a list of suggestions for the specified title.
     * @param {string} title The title of the item to search for
     * @returns {JQuery.Promise<WikidataSuggestion[], string> | null} A promise that resolves to an array of suggestions or rejects with an error message (or null if unavailable)
     */
    function getItemSuggestions(title){
        var deferred = $.Deferred();

        if(!mw.ForeignApi){
            return null;
        }

        // Strip the disambiguation part from the title
        title = title.replace(/([ _]*\([^)]+\))/g, '');

        var api = new mw.ForeignApi('https://www.wikidata.org/w/api.php', API_CONFIG);
        api.get({
            action: 'wbsearchentities',
            type: 'item',
            search: title,
            language: mw.config.get('wgContentLanguage'),
            uselang: mw.config.get('wgContentLanguage'),
            limit: 4,
            formatversion: 2
        }).then(function(data){
            var results = data.search || [];
            var suggestions = [];

            for(var i = 0; i < results.length; i++){
                var result = results[i];
                suggestions.push({
                    label: result.label,
                    description: result.description,
                    id: result.id
                });
            }
            deferred.resolve(suggestions);
        }).fail(function(code, result){
            deferred.reject(api.getErrorMessage(result));
        });

        return deferred.promise();
    }

    /**
     * Checks if the user can mark their edits as bot.
     * @returns {boolean}
     */
    function hasBotFlag(){
        var flagGroups = ['bot', 'flood'];
        var userGroups = mw.config.get('wgUserGroups');

        for(var i = 0; i < flagGroups.length; i++){
            if(userGroups.indexOf(flagGroups[i]) !== -1){
                return true;
            }
        }

        return false;
    }

    // Initialize only on editable pages in read mode
    if(!mw.config.get('wgIsProbablyEditable')) return;
    if(mw.config.get('wgAction') !== 'view') return;
    
    var ns = mw.config.get('wgNamespaceNumber');
    var pageTitle = mw.config.get('wgPageName');
    var isSubpage = pageTitle.indexOf('/') !== -1;
    
    var redLinks = getRedlinks();
    totalRedLinks = redLinks.length;
    if(totalRedLinks === 0) return;

    if(ns == 0 || (ns == 2 && isSubpage)){
        displayLinks(redLinks);
    } else {
        var link = mw.util.addPortletLink('p-tb', 'javascript:void(0)', TRIGGER_TEXT, 't-link-interwiki', TRIGGER_TOOLTIP);
        link.addEventListener('click', function(){
            displayLinks(redLinks);
        });
    }
});
/**
 * Describes the new wikitext and place where the modification was made.
 * Indices refer to the new wikitext.
 * @typedef {{
 *    newWikitext: string,
 *    newSanitizedWikitext: string,
 *    indexFrom: number,
 *    indexTo: number,
 * }} LinkReplaceState
 * 
 * Describes a suggestion for an item retrieved from Wikidata.
 * @typedef {{
 *    id: string,
 *    label: string | undefined,
 *    description: string | undefined
 * }} WikidataSuggestion
 */
// </nowiki>