Przejdź do zawartości

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)
  • 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 STORAGE_DIFF_KEY = 'gadget-link-interwiki-diff';

    var TRIGGER_TEXT = '{{link-interwiki}}';
    var TRIGGER_TOOLTIP = 'Zamień czerwone linki na szablon {{link-interwiki}}';
    var SHOW_DIFF_CHECKBOX = 'Podgląd zmian';
    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 TEMPLATE_IN_LINK = 'Uwaga: Link zawiera w sobie szablon lub inne znaczniki wikikodu. Jeśli kontynuujesz, zostaną one usunięte z tego linku.';
    var PROMPT_TEXT = 'Podaj identyfikator elementu w Wikidanych:';
    var PROMPT_PLACEHOLDER = 'np. Q123456';
    var INVALID_QID = 'Nie jest to poprawny identyfikator. Po kliknięciu „Dalej” zostanie umieszczony zwykły link do Wikipedii';
    var QID_ARTICLE_EXISTS = 'Element Wikidanych <a href="//wikidata.org/wiki/$1" target="_blank">$1</a> jest połączony z artykułem <a href="/wiki/$3" target="_blank">$2</a>. Jeśli przejdziesz dalej, cel linku w tym miejscu zostanie zmieniony, tak aby prowadzić do tego artykułu.';
    var ACCEPT_BTN = 'Zatwierdź';
    var UNLINK_BTN = 'Usuń link';
    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 SUGGESTION_UNAVAILABLE = 'Sugestie nie są dostępne';
    var SUGGESTION_LOAD_MORE = 'Wczytaj więcej';

    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 = {
        intro: 'Czerwone linki: ',
        template: 'zamieniono $1 na szablon {{[[Szablon:Link-interwiki|Link-interwiki]]}}',
        localLink: 'zamieniono $1 na lokalny link',
        unlink: 'usunięto $1'
    };

    var ERROR_TITLE = 'Wystąpił błąd';
    var ITEM_NOT_EXISTS = 'Element Wikidanych $1 nie istnieje.';

    var API_CONFIG = {
        parameters: {
            format: 'json',
            formatversion: 2,
            errorformat: 'html',
            errorlang: mw.config.get('wgUserLanguage'),
            errorsuselocal: true,
            maxage: 300,
            smaxage: 300,
        },
        userAgent: 'Gadget-wstaw-link-interwiki'
    };
    var QID_REGEX = /^Q?\d+$/i;

    // 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;

    /** @type {{[key in ChangeType]: number}} Number of changes per type */
    var changeTypes = {
        template: 0,
        localLink: 0,
        unlink: 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;
                }
            }

            // Set the normalized target page and link index as data attributes
            var targetPage = extractPageTitle(link.href);
            targetPage = normalizePageName(targetPage);
            if (getNamespace(targetPage) !== 0) {
                // We only want to process main namespace links
                continue;
            }
            
            var targetIndex = targets[targetPage] || 0;
            targets[targetPage] = targetIndex + 1;
            link.setAttribute(LINK_TARGET_INDEX, targetIndex);
            redlinks.push(link);
        }
        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(changeType){
                    numberOfChanges++;
                    changeTypes[changeType]++;
                    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<ChangeType, 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).done(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 = qid ? QID_REGEX.test(qid) : false;
                return createNewWikitext(localTitle, linkText, qid, isWikidata, targetIndex);
            }, localTitle);

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

        return deferred.promise();
    }

    /**
     * Creates a new wikitext, by replacing the link with a link to specified page.
     * @param {string} localTitle The human-friendly local page name
     * @param {string} linkText The visible link text
     * @param {string | null} linkTarget The target page name; pass null to delete the link
     * @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 {WikitextResult | null} Data about the new wikitext or null if the link could not be found
     */
    function createNewWikitext(localTitle, linkText, linkTarget, isWikidata, targetIndex){
        var newText;
        if (linkTarget !== null) {
            newText = prepareTemplate(localTitle, linkText, linkTarget, isWikidata);
        } else {
            newText = linkText;
        }
        var replaced = replaceLinkWithText(actualWikitext, sanitizedWikitext, localTitle, targetIndex, newText);

        if(!replaced) return null;

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

        /** @type {ChangeType} */
        var changeType = 'template';
        if (linkTarget === null) {
            changeType = 'unlink';
        } else if (!isWikidata) {
            changeType = 'localLink';
        }

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

    /**
     * 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) + '>';
        });

        // Transform [[File:]] syntax so that links inside are no longer "links in links" that break our regex
        wikitext = wikitext.replace(/\[\[(Plik|File|Grafika|Image):/gi, '(($1:');

        // 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, '_');
    }

    /**
     * Returns the namespace number that corresponds to the page title.
     * @param {string} title The page name
     * @returns {number} The namespace number corresponding to the page title
     */
    function getNamespace(title){
        var parts = title.split(':');
        if(parts.length == 1){
            return 0; // Default is main namespace
        }
        var namespaceName = parts[0].toLowerCase().replace(/ /g, '_');
        var namespaces = mw.config.get('wgNamespaceIds');
        if (namespaces[namespaceName] !== undefined) {
            return namespaces[namespaceName];
        }
        return 0;
    }

    /**
     * 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 class="insert-link-interwiki-savebox"></div>');
            saveBox.appendTo(document.body);

            counterBox = $('<div class="counter"></div>');
            counterBox.appendTo(saveBox);

            var diffCheckbox = new OO.ui.CheckboxInputWidget({
                selected: mw.storage.get(STORAGE_DIFF_KEY) === '1'
            });
            var diffField = new OO.ui.FieldLayout(diffCheckbox, {
                label: SHOW_DIFF_CHECKBOX,
                align: 'inline'
            });
            diffField.$element.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);

                var displayDiff = diffCheckbox.isSelected();
                mw.storage.set(STORAGE_DIFF_KEY, displayDiff ? '1' : '0');
                if (displayDiff) {
                    displayPreview();
                    return;
                } else {
                    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();
    }

    /**
     * Reads the current actualWikitext and prepares it for saving
     * by stripping unnecessary padding characters.
     * @returns {string}
     */
    function prepareWikitextForSaving() {
        var wikitext = actualWikitext;
        wikitext = wikitext.replace(/\x18/g, '');

        return wikitext;
    }

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

        var page = mw.config.get('wgPageName');
        var api = new mw.Api(API_CONFIG);
        api.postWithEditToken({
            action: 'edit',
            title: page,
            text: wikitext,
            summary: makeEditSummary(changeTypes),
            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();
    }

    /**
     * Reloads the page with a standard diff view.
     * In order to do so, a fake edit form is generated and submitted.
     * @returns {void}
     */
    function displayPreview() {
        // First, check the watchlist status of the current page, because MediaWiki is so dumb
        // that edit interface doesn't have option "nochange" for watchlist
        var apiParams = {
            action: 'query',
            prop: 'info',
            titles: mw.config.get('wgPageName'),
            inprop: 'watched',
        };
        var api = new mw.Api(API_CONFIG);

        var actuallyDisplayPreview = function(isPageWatched, watchlistExpiry){
            var wikitext = prepareWikitextForSaving();

            var formParams = {
                wpTextbox1: wikitext,
                wpDiff: 'wpDiff',
                wpSummary: makeEditSummary(changeTypes),
                wpMinoredit: '1',
                wpUnicodeCheck: 'ℳ𝒲♥𝓊𝓃𝒾𝒸ℴ𝒹ℯ',
                wpIgnoreBlankSummary: '1',
                wpWatchthis: isPageWatched ? '1' : undefined,
                wpWatchlistExpiry: watchlistExpiry,
                wpUltimateParam: '1',
            };

            // Generate the form
            var form = document.createElement('form');
            form.method = 'POST';
            form.action = mw.util.getUrl(mw.config.get('wgPageName'), { action: 'submit' });
            form.enctype = 'multipart/form-data';
            form.style.display = 'none';
            document.body.appendChild(form);

            Object.entries(formParams).forEach(function(row) {
                if (row[1] === undefined) return;
                var input = document.createElement('input');
                input.type = 'hidden';
                input.name = row[0];
                input.value = row[1];
                form.appendChild(input);
            });

            // Just to be sure, release the lock not to be disturbed by additional confirm dialogs
            if (windowCloseConfirm) windowCloseConfirm.release();
            windowCloseConfirm = undefined;

            // Submit the form
            form.submit();
        }

        api.get(apiParams).then(function(data){
            var isWatched = data.query.pages[0].watched;
            var watchlistExpiry = data.query.pages[0].watchlistexpiry;
            actuallyDisplayPreview(isWatched, watchlistExpiry);
        }).fail(function(code, result){
            // It's not a crucial information so fail silently
            var isWatched = false;
            var watchlistExpiry = undefined;
            actuallyDisplayPreview(isWatched, watchlistExpiry);
        });
    }

    /**
     * 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 {HTMLAnchorElement} anchor The anchor element to display the popup next to
     * @param {(qid: string|null) => (WikitextResult | null)} makeWikitext A function that returns the wikitexts and excerpt for the specified QID
     * @param {string} localTitle Title of the entity to search on Wikidata for suggestions
     * @returns {JQuery.Promise<WikitextResult, string>} A promise that resolves to the new wikitexts
     */
    function displayQidPopup(anchor, makeWikitext, localTitle){
        var deferred = $.Deferred();

        // Ask for the QID step
        var qidPanel = new OO.ui.PanelLayout({
            expanded: false
        });
        if (anchor.innerText != anchor.innerHTML) {
            // A hacky way to warn users that the link label may contain templates
            // To handle such a use case properly, the data model would need a significant refactoring
            var warningBox = new OO.ui.MessageWidget({
                type: 'warning',
                label: TEMPLATE_IN_LINK
            });
            qidPanel.$element.append(warningBox.$element);
        }

        var fieldset = new OO.ui.FieldsetLayout({});
        qidPanel.$element.append(fieldset.$element);

        var qidInput = new OO.ui.TextInputWidget({
            placeholder: PROMPT_PLACEHOLDER,
            autocomplete: false
        });
        var qidLayout = new OO.ui.FieldLayout(qidInput, {
            label: PROMPT_TEXT,
            align: 'top'
        });
        fieldset.addItems([qidLayout]);
        
        var qidUnlinkButton = new OO.ui.ButtonWidget({
            label: UNLINK_BTN,
            flags: ['destructive'],
        });
        qidUnlinkButton.$element.css('float', 'left');
        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(qidUnlinkButton.$element);
        qidButtonLayout.append(qidCancelButton.$element);
        qidButtonLayout.append(qidNextButton.$element);
        qidPanel.$element.append(qidButtonLayout);

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

        // If specified element is connected to local article
        // ask to change to local link instead
        var localLinkPanel = new OO.ui.PanelLayout({
            expanded: false
        });
        var localLinkText = $('<div></div>');
        localLinkPanel.$element.append(localLinkText);

        var localLinkNextButton = new OO.ui.ButtonWidget({
            label: NEXT_BTN,
            flags: ['primary', 'progressive']
        });
        var localLinkBackButton = new OO.ui.ButtonWidget({
            label: BACK_BTN
        });
        var localLinkButtonLayout = $('<div style="text-align:right; margin-top:8px"></div>');
        localLinkButtonLayout.append(localLinkBackButton.$element);
        localLinkButtonLayout.append(localLinkNextButton.$element);
        localLinkPanel.$element.append(localLinkButtonLayout);

        // 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, localLinkPanel, 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(!QID_REGEX.test(qid) && qid.length > 0){
                qidLayout.setWarnings([INVALID_QID]);
            }else{
                qidLayout.setWarnings([]);
            }
        });
        qidNextButton.on('click', function(){
            var enteredQid = qidInput.getValue();
            qidInput.pushPending();

            getLocalArticleForQid(enteredQid).then(function(localArticle){
                qidInput.popPending();
                newWikitext = makeWikitext(localArticle || enteredQid);
                if(!newWikitext){
                    popup.toggle(false);
                    return deferred.reject(ERROR_TEXT);
                }
                var message = mw.format(CONFIRM_WIKITEXT_TEXT, newWikitext.excerpt);
    
                promptText.empty();
                promptText.append(
                    $(message)
                );

                if (localArticle === null) {
                    contentStack.setItem(wikitextPanel);
                } else {
                    var urlencodedTitle = encodeURIComponent(localArticle);
                    localLinkText.html(mw.format(QID_ARTICLE_EXISTS, enteredQid, localArticle, urlencodedTitle));
                    localLinkNextButton.setDisabled(false);
                    contentStack.setItem(localLinkPanel);
                }
            }).fail(function(reason) {
                qidInput.popPending();
                localLinkText.html(reason);
                localLinkNextButton.setDisabled(true);
                contentStack.setItem(localLinkPanel);
            });
        });
        qidCancelButton.on('click', function(){
            popup.toggle(false);
            deferred.reject(null);
        });
        qidUnlinkButton.on('click', function(){
            newWikitext = makeWikitext(null);
            popup.toggle(false);
            if (!newWikitext) {
                deferred.reject(ERROR_TEXT);
            } else {
                anchor.style.color = 'inherit';
                deferred.resolve(newWikitext);
            }
        });
        localLinkNextButton.on('click', function(){
            contentStack.setItem(wikitextPanel); // Everything has been prepared in the previous step
        });
        localLinkBackButton.on('click', function(){
            contentStack.setItem(qidPanel);
        });
        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 {string} localTitle Title for which to search for suggestions
     * @param {(qId: string) => void} onSelect A function that is called when the user selects a suggestion (with QId as parameter)
     * @returns {JQuery<HTMLElement>}
     */
    function renderSuggestionList(localTitle, onSelect){
        var searchContinue = null;
        var searchPromise = getItemSuggestions(localTitle, 0, 4);

        var $suggestionsContainer = $('<div class="suggestions"></div>');
        if (!searchPromise){
            $suggestionsContainer.append('<div class="error">' + SUGGESTION_UNAVAILABLE + '</div>');
            return $suggestionsContainer;
        }

        $suggestionsContainer.append('<div class="header">' + SUGGESTION_HEADER + '</div>');

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

        searchPromise.done(function(searchResult){
            var suggestions = searchResult.items;
            searchContinue = searchResult.continue;

            if(suggestions.length === 0){
                $emptyState.text(SUGGESTION_NO_RESULTS);
                return;
            }
    
            $emptyState.remove();
    
            var $suggestionsList = $('<ul></ul>');
            suggestions.forEach(function(suggestion) {
                addSuggestionItem($suggestionsList, suggestion);
            });
            $suggestionsContainer.append($suggestionsList);

            if (searchContinue) {
                var $loadMoreButton = $('<a href="javascript:void(0)" class="load-more">' + SUGGESTION_LOAD_MORE + '</a>');
                $suggestionsContainer.append($loadMoreButton);
                $loadMoreButton.on('click', function(){
                    $loadMoreButton.prop('disabled', true);
                    var searchPromise = getItemSuggestions(localTitle, searchContinue, 8);
                    if (!searchPromise){
                        $loadMoreButton.prop('disabled', false);
                        return;
                    }

                    searchPromise.then(function(searchResult){
                        searchContinue = searchResult.continue;
                        var suggestions = searchResult.items;
                        suggestions.forEach(function(suggestion) {
                            addSuggestionItem($suggestionsList, suggestion);
                        });
                        $loadMoreButton.prop('disabled', false);

                        if (!searchContinue) {
                            $loadMoreButton.remove();
                        }
                    }).fail(function(){
                        $loadMoreButton.prop('disabled', false);
                    });
                });
            }
        }).fail(function(error){
            $emptyState.text(mw.format(SUGGESTION_ERROR, error));
        });

        /** @type {(HTMLElement, WikidataSuggestion) => void} */
        var addSuggestionItem = function($suggestionsList, 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" class="skin-invert" />');
            $suggestionItem.append($wikidataLink);
            $suggestionsList.append($suggestionItem);
        };

        return $suggestionsContainer;
    }

    /**
     * Returns a list of suggestions for the specified title.
     * @param {string} title The title of the item to search for
     * @param {number} skipResults The number of results to skip (for pagination)
     * @param {number} limit The maximum number of results to return
     * @returns {JQuery.Promise<{ items: WikidataSuggestion[], continue: number | undefined }, string> | null} A promise that resolves to an array of suggestions or rejects with an error message (or null if unavailable)
     */
    function getItemSuggestions(title, skipResults, limit){
        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: limit,
            continue: skipResults,
            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({
                items: suggestions,
                continue: data['search-continue']
            });
        }).fail(function(code, result){
            deferred.reject(api.getErrorMessage(result));
        });

        return deferred.promise();
    }

    /**
     * Fetches the local article attached to the specified Wikidata item.
     * Returns null if the item does not exist or is not connected to the local wiki article.
     * @param {string} qid 
     * @returns {JQuery.Promise<string | null>}
     */
    function getLocalArticleForQid(qid){
        var deferred = $.Deferred();

        // Invalid QIDs can't have local articles
        if(!QID_REGEX.test(qid)){
            deferred.resolve(null);
            return deferred.promise();
        }

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

        var api = new mw.ForeignApi('https://www.wikidata.org/w/api.php', API_CONFIG);
        var dbname = mw.config.get('wgDBname');
        api.get({
            action: 'wbgetentities',
            ids: qid,
            props: 'sitelinks',
            sitefilter: dbname,
            language: mw.config.get('wgContentLanguage'),
            uselang: mw.config.get('wgContentLanguage'),
            formatversion: 2
        }).then(function(data){
            var result = data.entities[qid];
            if (!result) {
                deferred.reject(mw.format(ITEM_NOT_EXISTS, qid));
                return;
            }

            var sitelinks = result.sitelinks;
            if (!sitelinks || !sitelinks[dbname]) {
                deferred.resolve(null);
                return;
            }

            var localTitle = sitelinks[dbname].title;
            deferred.resolve(localTitle);
        }).fail(function(code, result){
            if (code === 'no-such-entity') {
                deferred.reject(mw.format(ITEM_NOT_EXISTS, qid));
            }

            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;
    }

    /**
     * Prepares an edit summary based on the number of different changes.
     * @param {{[key in ChangeType]: number}} changeTypes Number of changes for each type
     * @returns {string} The edit summary
     */
    function makeEditSummary(changeTypes) {
        var summaryParts = [];
        for (var type in changeTypes) {
            if (changeTypes[type] == 0) continue;

            summaryParts.push(mw.format(EDIT_SUMMARY[type], changeTypes[type]));
        }
        return EDIT_SUMMARY.intro + summaryParts.join(', ');
    }

    // 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
 * 
 * @typedef {{
 *    actualWikitext: string,
 *    sanitizedWikitext: string,
 *    excerpt: string,
 *    changeType: ChangeType
 * }} WikitextResult
 * 
 * @typedef {'template' | 'localLink' | 'unlink'} ChangeType
 */
// </nowiki>