MediaWiki:Gadget-AjaxQuickDelete.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.
/* eslint-disable array-bracket-newline */
/* eslint-disable array-element-newline */
/* eslint-disable indent */
/* global $, mw, OO */
/**
 * @author [[c:User:Ilmari Karonen]] (original code)
 * @author [[c:User:DieBuche]] (rewritten & extended)
 * @author [[c:User:Lupo]] (bot detection and encoding fixer)
 * @author [[w:pl:User:Lampak]] (adaptation for Polish Wikipedia)
 * @author [[w:pl:User:Msz2001]] (rewrite for OOUI and code cleanup)
 * 
 * Originally written on Wikimedia Commons as a replacement for [[c:MediaWiki:Quick-delete-code.js]]
 * 
 * Full list of authors:
 *  - original version: https://commons.wikimedia.org/w/index.php?title=MediaWiki:Gadget-AjaxQuickDelete.js&action=history
 *  - adapted on plwiki: https://pl.wikipedia.org/w/index.php?title=MediaWiki:Gadget-AjaxQuickDelete.js&action=history
 * 
 * Idea for quick reasons for speedy deletion and copyvio source based on [[w:pl:User:Krzysiek 123456789]]'s fork of this gadget
 * 
 * <nowiki>
 */
(function () {
    //! Translatable messages
    var MSG = {
        toolboxLink: "Zgłoś do usunięcia",
        dialogTitle: "Zgłaszanie do usunięcia",
        dialogCancel: "Anuluj",
        dialogSubmit: "Zatwierdź",

        dnuReasonLabel: "Wyjaśnij dokładnie, dlaczego ta strona nie nadaje się do Wikipedii:",
        dnuReasonPlaceholder: "Uzasadnienie zgłoszenia",
        dnuReasonHelp: "Twoje zgłoszenie zostanie automatycznie podpisane.",
        dnuRequestTypeLabel: "Rodzaj zgłoszenia",
        dnuWikiprojectsToNotify: "Jakie wikiprojekty powiadomić?",
        dnuSpeedyNotice: "Jeżeli strona spełnia <a href='https://pl.wikipedia.org/wiki/Wikipedia:Ekspresowe_kasowanie' target='_blank'><b>zasady ekspresowego kasowania</b></a>, zamiast rozpoczynać nad nią dyskusję możesz zgłosić ją do <b>ekspresowego kasowania</b>. Aby to zrobić, wybierz opcję „Ekspresowe kasowanie” powyżej i podaj powód, a następnie kliknij przycisk „Zatwierdź”.",
        dnuLobbyMarker: "<!-- Nowe zgłoszenia wstawiaj poniżej tej linii. Nie usuwaj tej linii -->",
        dnuSubpageCreateSummary: "Nowe zgłoszenie do usunięcia",
        dnuRequestedPageEditSummary: "Zgłoszono do usunięcia",
        dnuLobbyEditSummary: "Dodano [[:$1]]",
        dnuTalkSectionTitle: "DNU: [[:$1]]",
        dnuTalkSummary: "Strona [[:$1]] została zgłoszona do usunięcia",
        dnuCheckSources: "{{Sprawdź w źródłach|$1}}",
        dnuCheckSourcesInsert: "Wstaw szablon <kbd>{{Sprawdź w źródłach}}</kbd>",

        ekReasonLabel: "Uzasadnienie zgłoszenia do ekspresowego kasowania:",
        ekReasonPlaceholder: "Wpisz lub wybierz z listy powód zgłoszenia",
        ekCopyvioLinkLabel: "Jeżeli treść narusza prawa autorskie, możesz podać link do oryginalnej publikacji",
        ekCopyvioLinkPlaceholder: "Wklej link do strony, z której pochodzi artykuł",
        ekSpeedyNotice: "Do ekspresowego kasowania można zgłaszać wyłącznie strony, które spełniają <a href='https://pl.wikipedia.org/wiki/Wikipedia:Ekspresowe_kasowanie' target='_blank'><b>zasady usuwania tym trybem</b></a>. W przeciwnym razie należy rozpocząć <b>dyskusję nad usunięciem</b>. Aby to zrobić, wybierz opcję „Dyskusja nad usunięciem” powyżej i podaj powód oraz rodzaj zgłoszenia, a następnie kliknij przycisk „Zatwierdź”.",
        ekPageEditSummary: "Zgłoszono do [[:Kategoria:Ekspresowe kasowanie|ekspresowego kasowania]]",

        apiFail: "Żądanie API zakończyło się błędem: $1",
        cantGetAuthor: "Nie udało się pobrać autora strony.",
        cantCreateRequestSubpage: "Nie udało się utworzyć podstrony Poczekalni dla zgłoszenia.",
        cantPrependTemplate: "Nie udało się wstawić szablonu do artykułu. Podstrona dla zgłoszenia istnieje, ale proces należy dokończyć ręcznie. Wstaw na początek artykułu szablon $1, a następnie poinformuj autora artykułu o zgłoszeniu go do Poczekalni.",
        cantAddToLobby: "Nie udało się dołączyć zgłoszenia na głównej stronie Poczekalni. Przejdź na stronę <a href='/wiki/$1'>$1</a>, a następnie dopisz na niej kod $2.",
        cantNotifyUsers: "Zgłoszenie zostało utworzone, lecz nie udało się powiadomić o nim autora artykułu.",
        cantNotifyWikiprojects: "Zgłoszenie zostało utworzone, lecz nie udało się powiadomić o nim wikiprojektów i autora artykułu.",
    };

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

    //! List of the available deletion procedures
    // label: the displayed text
    // title: the tooltip text
    // createView: a function with no arguments that creates a view with all the controls. The return type must comply with type View as at the end of the document.
    var AVAILABLE_PROCEDURES = [
        {
            label: 'Dyskusja nad usunięciem',
            title: 'Zgłoś do Poczekalni',
            createView: createViewDNU
        },
        {
            label: 'Ekspresowe kasowanie',
            title: 'Zgłoś do Ekspresowego kasowania',
            createView: createViewEK
        }
    ];

    //! List of request types available for DNU
    var REQUEST_TYPES = [
        {
            templateParam: 'artykuł',
            subpage: 'artykuły',
            label: 'artykuł (niebędący biografią)',
            isMetapage: false
        },
        {
            templateParam: 'biografia',
            subpage: 'biografie',
            label: 'biografia',
            isMetapage: false
        },
        {
            templateParam: 'technikalia',
            subpage: 'kwestie techniczne',
            label: 'technikalia (np. szablon, kategoria)',
            isMetapage: true
        }
    ];

    //! Users in these categories will not be notified about the deletion request
    var NEGATIVE_USER_CATEGORIES = [
        'Zmarli wikipedyści'
    ];

    // The current state of the dialog (used to save the user input after closing)
    var dialogState = null;

    /**
     * Checks what is the default request type for the current page
     * @returns {number} The request type index
     */
    function getDefaultRequestType(){
        var NS = mw.config.get('wgNamespaceIds');
        var namespace = mw.config.get('wgNamespaceNumber');
        
        // Templates and categories go to technical bucket
        if (namespace === NS.template || namespace === NS.category) {
            return 2;
        }

        // Biographies, detected by categories
        var categories = mw.config.get('wgCategories');
        for(var i = 0; i < categories.length; i++){
            var category = categories[i];

            if(/^Urodzeni w /.test(category)
                || /^Zmarli w /.test(category)){
                return 1;
            }
        }

        // Everything else is an article
        return 0;
    }

    //! List of presets for speedy deletion
    var SPEEDY_PRESETS = [
        'bezsens', 'brudnopis', 'dane osobowe', 'hoax', 'moje', 'nieaktualne',
        'nieency', 'npa', 'or', 'plik', 'powiązany artykuł zamieniono na przekierowanie',
        'redir', 'reklama', 'substub', 'techniczne', 'test', 'translator',
        'wandalizm', 'wygłup', 'zniesławienie'
    ];

    /**
     * Computes the title of a subpage with the deletion request
     * @param {string} pageTitle The title of the page that is requested to be deleted
     * @param {number} requestType The index of request type in REQUEST_TYPES
     * @returns {{fullTitle: string, subpage: string, lobbyPage: string}}
     */
    function getDnuSubpageTitle(pageTitle, requestType) {
        var pad0 = function (s) { s = "" + s; return (s.length > 1 ? s : "0" + s); };  // zero-pad to two digits
        var date = new Date();

        var requestSubpage = '';
        requestSubpage += date.getUTCFullYear() + ':';
        requestSubpage += pad0(date.getUTCMonth() + 1) + ':';
        requestSubpage += pad0(date.getUTCDate()) + ':';
        requestSubpage += pageTitle;

        var lobbyPage = 'Wikipedia:Poczekalnia/';
        lobbyPage += REQUEST_TYPES[requestType].subpage;

        var requestFullTitle = lobbyPage + '/' + requestSubpage;
        return {
            fullTitle: requestFullTitle,
            subpage: requestSubpage,
            lobbyPage: lobbyPage
        };
    }

    //! Actual start of the gadget implementation
    /** Installs the gadget (entry point) */
    function install() {
        // Can't delete special pages
        if(mw.config.get('wgNamespaceNumber') < 0) return;

        // Create a toolbox link
        var portletId = mw.config.get('skin') === 'timeless' ? 'p-pagemisc' : 'p-tb';
        var link = mw.util.addPortletLink(portletId, '#', MSG.toolboxLink, 't-ajaxquickdelete', null);
        // bind toolbox link
        $(link).click(function (e) {
            e.preventDefault();
            mw.loader.using(
                ['oojs-ui-core', 'oojs-ui-widgets', 'oojs-ui-windows', 'mediawiki.api', 'mediawiki.Title', 'mediawiki.messagePoster'],
                function () {
                    displayRequestDeleteDialog();
                }
            );
        });
    }

    //! Start of the dialog window definition
    function displayRequestDeleteDialog() {
        var currentView = null;

        // Initialize the dialog's class
        function RequestDeleteDialog(config) {
            RequestDeleteDialog.super.call(this, config);
        }
        OO.inheritClass(RequestDeleteDialog, OO.ui.ProcessDialog);

        RequestDeleteDialog.static.name = "RequestDeleteDialog";
        RequestDeleteDialog.static.title = MSG.dialogTitle;
        RequestDeleteDialog.static.actions = [
            {
                label: MSG.dialogCancel,
                flags: "safe",
            },
            {
                action: 'submit',
                label: MSG.dialogSubmit,
                flags: ["destructive", "primary"]
            }
        ];

        // Populate the dialog with controls
        RequestDeleteDialog.prototype.initialize = function () {
            RequestDeleteDialog.super.prototype.initialize.call(this);

            // Create layout
            var rootPanel = new OO.ui.PanelLayout({
                padded: true,
                expanded: false,
            });

            // Procedures select
            var panels = [];
            var procedureOptions = AVAILABLE_PROCEDURES.map(function (p, i) {
                var view = p.createView();
                panels.push(view.content);

                if(i == 0) currentView = view;

                return new OO.ui.ButtonOptionWidget({
                    data: {
                        index: i,
                        view: view
                    },
                    label: p.label,
                    title: p.title,
                    selected: i == 0
                });
            });
            var procedureSelect = new OO.ui.ButtonSelectWidget({
                items: procedureOptions,
                classes: ['center']
            });

            var procedureWrapper = $('<div style="margin-bottom:12px">');
            procedureWrapper.append(procedureSelect.$element);

            // The container for panels for each procedure
            var formBody = new OO.ui.StackLayout({
                expanded: false,
                items: panels
            });
            formBody.setItem(panels[0]);

            rootPanel.$element.append(
                procedureWrapper,
                formBody.$element
            );
            this.$body.append(rootPanel.$element);

            currentView.applySynchronizedState(dialogState);

            // Handle procedure changes
            procedureSelect.on('select', function (item) {
                var state = currentView.getSynchronizedState();
                currentView = item.data.view;
                formBody.setItem(currentView.content);
                currentView.applySynchronizedState(state);
            });
        };

        RequestDeleteDialog.prototype.getActionProcess = function (action) {
            var dialog = this;
            if(action === 'submit') {
                if(currentView) return currentView.proceed(dialog);
            }
            if(!action && currentView) {
                dialogState = currentView.getSynchronizedState();
            }
            return RequestDeleteDialog.super.prototype.getActionProcess.call(this, action);
        };

        // Make the window
        var requestDeleteDialog = new RequestDeleteDialog({
            size: "large",
        });

        // Create and append a window manager
        var windowManager = new OO.ui.WindowManager();
        $(document.body).append(windowManager.$element);
        windowManager.addWindows([requestDeleteDialog]);
        windowManager.openWindow(requestDeleteDialog);
    }

    //! Define the views for different procedures of deletion
    /**
     * Creates a view for the standard deletion request
     * @returns {View} The view
     */
    function createViewDNU() {
        // Create layout
        var contentPanel = new OO.ui.PanelLayout({
            expanded: false
        });

        var fieldset = new OO.ui.FieldsetLayout({});

        var $reasonHelp = $('<div></div>');
        $reasonHelp.append('<p>' + MSG.dnuReasonHelp + '</p>');
        
        // Check sources link
        var $checkSourcesLink = $('<a href="javascript:void(0)">' + MSG.dnuCheckSourcesInsert + '</a>');
        $reasonHelp.append($checkSourcesLink);

        // Reason field
        var reasonInput = new OO.ui.MultilineTextInputWidget({
            placeholder: MSG.dnuReasonPlaceholder,
            multiline: true,
            rows: 5,
        });

        var reasonLayout = new OO.ui.FieldLayout(reasonInput, {
            label: MSG.dnuReasonLabel,
            align: 'top',
            help: $reasonHelp,
            helpInline: true
        });

        $checkSourcesLink.on('click', function (e) {
            e.preventDefault();

            var pageTitle = mw.config.get('wgPageName').replace(/_/g, ' ');
            var template = mw.format(MSG.dnuCheckSources, pageTitle);
            var currentValue = reasonInput.getValue();

            if(currentValue.indexOf(template) != -1) return;
            reasonInput.setValue(template + '\n' + currentValue);
        });

        // Type of request
        var typeOptions = REQUEST_TYPES.map(
            function (t, i) { return { data: i, label: t.label }; }
        );
        var requestTypeSelect = new OO.ui.DropdownInputWidget({
            options: typeOptions,
            value: getDefaultRequestType()
        });

        var requestTypeLayout = new OO.ui.FieldLayout(requestTypeSelect, {
            label: MSG.dnuRequestTypeLabel
        });

        var onRequestTypeChange = function(){
            var type = REQUEST_TYPES[requestTypeSelect.getValue()];
            if(!type) return;

            $checkSourcesLink.css('visibility', type.isMetapage ? 'hidden' : 'visible');
        };
        requestTypeSelect.on('change', onRequestTypeChange);
        onRequestTypeChange();

        // Notify wikiprojects
        var wikiprojectsSelect = new OO.ui.MenuTagMultiselectWidget({
            inputPosition: 'inline',
            options: []
        });

        var wikiprojectsLayout = new OO.ui.FieldLayout(wikiprojectsSelect, {
            label: MSG.dnuWikiprojectsToNotify
        });

        // The wikiprojects select is populated asynchronously
        gadget.getWikiprojects({includeGadgets: ['dnu']}).then(function (result) {
            var wikiprojectOptions = result.wikiprojects.map(
                function (wikiproject) {
                    return {
                        data: wikiproject.page,
                        label: wikiproject.name
                    };
                }
            );
            wikiprojectsSelect.addOptions(wikiprojectOptions);
        });

        // Speedy deletion notice
        var speedyNotice = $("<p>").html(MSG.dnuSpeedyNotice);

        // Add the inputs to the fieldset
        fieldset.addItems([
            reasonLayout,
            requestTypeLayout,
            wikiprojectsLayout
        ]);

        // And finally append all the controls to the window
        contentPanel.$element.append(
            fieldset.$element,
            speedyNotice
        );
        return {
            content: contentPanel,
            proceed: function (dialog) {
                var reason = reasonInput.getValue();
                var requestType = requestTypeSelect.getValue();
                var wikiprojects = wikiprojectsSelect.getValue();

                return controlDnuRequest({
                    reason: reason,
                    requestType: requestType,
                    wikiprojects: wikiprojects,
                    dialog: dialog
                });
            },
            getSynchronizedState: function () {
                return {
                    reason: reasonInput.getValue()
                };
            },
            applySynchronizedState: function (state) {
                if(state && state.reason) reasonInput.setValue(state.reason);
            }
        };
    }

    /**
     * Creates a view for the speedy deletion request
     * @returns {View} The view
     */
    function createViewEK() {
        // Create layout
        var contentPanel = new OO.ui.PanelLayout({
            expanded: false
        });

        var fieldset = new OO.ui.FieldsetLayout({});

        // Reason field
        var reasonInput = new OO.ui.ComboBoxInputWidget({
            placeholder: MSG.ekReasonPlaceholder,
            options: SPEEDY_PRESETS.map(function (p) { return { data: p }; })
        });
        var reasonLayout = new OO.ui.FieldLayout(reasonInput, {
            label: MSG.ekReasonLabel,
            align: 'top'
        });

        // Copyvio source
        var copyvioLinkInput = new OO.ui.TextInputWidget({
            placeholder: MSG.ekCopyvioLinkPlaceholder
        });
        var copyvioLayout = new OO.ui.FieldLayout(copyvioLinkInput, {
            label: MSG.ekCopyvioLinkLabel,
            align: 'top'
        });

        // Speedy deletion notice
        var speedyNotice = $("<p>").html(MSG.ekSpeedyNotice);

        // Add the inputs to the fieldset
        fieldset.addItems([
            reasonLayout,
            copyvioLayout
        ]);

        // And finally append all the controls to the window
        contentPanel.$element.append(
            fieldset.$element,
            speedyNotice
        );
        return {
            content: contentPanel,
            proceed: function (dialog) {
                var reason = reasonInput.getValue();
                var copyvioLink = copyvioLinkInput.getValue();

                return controlEkRequest({
                    reason: reason,
                    copyvioLink: copyvioLink,
                    dialog: dialog
                });
            },
            getSynchronizedState: function () {
                return {
                    reason: reasonInput.getValue()
                };
            },
            applySynchronizedState: function (state) {
                if(state && state.reason) reasonInput.setValue(state.reason);
            }
        };
    }

    //! The actual steps that are executed during the nomination for deletion
    /**
     * Controls the flow of the DNU request
     * @param {{reason: string, requestType: number, wikiprojects: string[], dialog: OO.ui.ProcessDialog}} data Data about the request
     * @returns {{OO.ui.Process}} A process representing the activity
     */
    function controlDnuRequest(data) {
        var pageTitle = mw.config.get('wgPageName').replaceAll('_', ' ');
        var requestPage = getDnuSubpageTitle(pageTitle, data.requestType);

        // Prepare the template to be added to the article
        var templateRequestType = REQUEST_TYPES[data.requestType].templateParam;
        var pageTemplate = '{{DNU|' + templateRequestType + '|podstrona=' + requestPage.subpage + '}}';
        var talkTemplate = "{{DNUinfo|" + pageTitle + '|' + requestPage.fullTitle + "|" + templateRequestType + "}}";
        var talkMessage = talkTemplate + " ~~~~";

        // Ensure that the reason will have the signature on the end
        var reason = data.reason;
        reason = reason.replace('~~~~', '');
        reason = reason.trim() + ' ~~~~';

        // Prepare the content of the request page
        var subpageText = "== [[:" + pageTitle + "]] ==\n";
        subpageText += ": {{lnDNU|" + pageTitle + "|strona={{subst:FULLPAGENAME}}}}\n";
        subpageText += reason;

        var usersToNotify = [];
        var wikiprojectPages = data.wikiprojects;

        // Construct the process to be invoked
        return new OO.ui.Process()
            .next(function () {
                return getCurrentPageCreator().then(function (users) { usersToNotify = users; });
            })
            .next(function () {
                return filterOutUsersInCategories(usersToNotify, NEGATIVE_USER_CATEGORIES)
                    .then(function (filteredUsers) { usersToNotify = filteredUsers; });
            })
            .next(function () {
                return createRequestPage(requestPage.fullTitle, subpageText);
            })
            .next(function () {
                return markArticleWithTemplate(pageTemplate, MSG.dnuRequestedPageEditSummary);
            })
            .next(function () {
                return addRequestToLobby(requestPage.lobbyPage, requestPage.fullTitle);
            })
            .next(function () {
                return notifyWikiprojects(wikiprojectPages, talkTemplate);
            })
            .next(function () {
                return createMessagePosters(usersToNotify).then(function (posters) {
                    return notifyUsers(posters, talkMessage);
                });
            })
            .next(function () {
                data.dialog.close({ action: 'submit' });
                reloadPage();
            });
    }

    /**
     * Controls the flow of the speedy deletion request
     * @param {{reason: string, copyvioLink: string, dialog: OO.ui.ProcessDialog}} data Data about the request
     * @returns {{OO.ui.Process}} A process representing the activity
     */
    function controlEkRequest(data) {
        var copyvioLink = data.copyvioLink || '';
        if(copyvioLink.length > 0) {
            copyvioLink = '|' + copyvioLink;
        }

        // Just in case, not to break the template
        var reason = data.reason.replace('|', '{{!}}').replace('=', '{{=}}');

        // Prepare the template to be added to the article
        var pageTemplate = '{{ek|' + reason + copyvioLink + '}}';

        return new OO.ui.Process()
            .next(function () {
                return markArticleWithTemplate(pageTemplate, MSG.ekPageEditSummary);
            })
            .next(function () {
                data.dialog.close({ action: 'submit' });
                reloadPage();
            });
    }

    //! Utility functions
    /**
     * Retrieves the creators of the current page.
     * 
     * @returns {jQuery.Promise<string[]>} A promise resolving to the current page creators.
     */
    function getCurrentPageCreator() {
        var deferred = $.Deferred();

        // Prepare the query parameters for the API
        var pageTitle = mw.config.get('wgPageName');
        var query = {
            curtimestamp: true,
            action: 'query',
            prop: 'revisions',
            rvprop: 'user',
            rvlimit: 1,
            rvdir: 'newer',
            titles: pageTitle
        };

        // Different query for File: namespace
        var NS_FILE = mw.config.get('wgNamespaceIds').file;
        if(mw.config.get('wgNamespaceNumber') === NS_FILE) {
            query = {
                curtimestamp: true,
                action: 'query',
                meta: 'tokens',
                prop: 'imageinfo',
                iiprop: ['user', 'sha1', 'comment'],
                iilimit: 50,
                titles: pageTitle
            };
        }

        // Invoke the API
        doAPICall(query).done(
            function (result) {
                var usersToNotify = [];

                if(mw.config.get('wgNamespaceNumber') !== NS_FILE) {
                    // For articles, notify only the original author
                    // Provided that he/she is not an anon
                    var revision = result.query.pages[0].revisions[0];
                    if(revision.anon === undefined) {
                        var creator = revision.user;
                        usersToNotify = [creator];
                    }
                } else {
                    // For files, notify each uploader but not anons
                    var info = result.query.pages[0].imageinfo;

                    for(var i = 0; i <= info.length; i++) {
                        if(info[i].anon === undefined) {
                            usersToNotify.push(info[i].user);
                        }
                    }
                }

                // Remove duplicates
                usersToNotify = usersToNotify.filter(function (item, pos, self) {
                    return self.indexOf(item) == pos;
                });

                // Don't notify the user who makes the request nor IPs
                var currentUser = mw.config.get('wgUserName');
                usersToNotify = usersToNotify.filter(function (user) { return user != currentUser; });

                // Resolve the promise with the list of users
                deferred.resolve(usersToNotify);
            }
        ).fail(function (reason) {
            deferred.reject(new OO.ui.Error(MSG.cantGetAuthor + '\n' + reason));
        });

        return deferred.promise();
    }

    /**
     * Filters out the users that are in the specified categories
     * 
     * @param {string[]} users The original usernames that would be to be notified
     * @param {string[]} negativeCategories An array of category names that exclude its members from being notified (skip the category namespace prefix)
     * @returns {jQuery.Promise<string[]>} A promise resolving to the list of users to be notified
     */
    function filterOutUsersInCategories(users, negativeCategories) {
        var deferred = $.Deferred();

        // Short-circuit if there are no categories or no users
        if(negativeCategories.length === 0 || users.length === 0) {
            return deferred.resolve(users).promise();
        }

        var userNS = mw.config.get('wgFormattedNamespaces')[2] + ':';
        var userPages = users.map(function (user) {
            return userNS + user;
        });

        // Add the Category: namespace prefix to the category names
        // so that we can compare the input with the API response
        var categoryNS = mw.config.get('wgFormattedNamespaces')[14] + ':';
        negativeCategories = negativeCategories.map(function (category) {
            return categoryNS + category;
        });

        var query = {
            action: 'query',
            prop: 'categories',
            titles: userPages.join('|'),
            formatversion: 2,
            cllimit: 500
        };

        // Perhaps we should support `clcontinue` but no user will be in >500 categories ever
        doAPICall(query).done(function (result) {
            var filteredUsers = [];

            var pages = result.query.pages;
            pages.forEach(function (page) {
                // Extract the category names from the response
                var categories = page.categories || [];
                var categoryNames = categories.map(function (category) {
                    return category.title;
                });

                // Whether the user is in one of `negativeCategories`
                var includesNegativeCategory = categoryNames.some(function (category) {
                    return negativeCategories.indexOf(category) !== -1;
                });

                if(!includesNegativeCategory) {
                    // Strip the user namespace prefix so as not to change the data
                    var userName = page.title.replace(/^[^:]+:/, '');
                    filteredUsers.push(userName);
                }
            });

            deferred.resolve(filteredUsers);
        }).fail(function (reason) {
            // Assume that the user is not in any of the categories
            deferred.resolve(users);
        });

        return deferred.promise();
    }

    /**
     * Creates the page with the request
     * 
     * Expects a context to be bound as `this`.
     * The deletion reason has to be passed as `reason: string` in the context.
     * The name of the request page has to be passed as `requestPageTitle: string` in the context.
     * 
     * @param {string} requestPageTitle The title of the page to be created
     * @param {string} subpageText The content to be put on the new page
     * @returns {jQuery.Promise<void>} A promise representing the API operation.
     */
    function createRequestPage(requestPageTitle, subpageText) {
        var deferred = $.Deferred();

        var watchlist = 'watch';
        if(window.AjaxDeleteDontWatchRequests) watchlist = 'nowatch';

        // Try to create the request subpage
        api.create(
            requestPageTitle,
            {
                summary: MSG.dnuSubpageCreateSummary,
                watchlist: watchlist
            },
            subpageText
        ).then(function () {
            deferred.resolve();
        }).fail(function (code, result) {
            var errorMessage = api.getErrorMessage(result).text();
            return deferred.reject(new OO.ui.Error(MSG.cantCreateRequestSubpage + '\n' + errorMessage));
        });
        return deferred.promise();
    }

    /**
     * Prepends the template to the current page. If the current page is
     * in the Template: namespace, the `template` parameter is automatically
     * wrapped into noinclude tags.
     * 
     * @param {string} template The template to be added
     * @param {string} editSummary The edit summary on the article page
     * @returns {jQuery.Promise<void>} A promise representing the API operation.
     */
    function markArticleWithTemplate(template, editSummary) {
        var deferred = $.Deferred();

        var pageTitle = mw.config.get('wgPageName');
        var pageNamespace = mw.config.get('wgNamespaceNumber');
        var NS_TEMPLATE = mw.config.get('wgNamespaceIds').template;

        if(pageNamespace == NS_TEMPLATE) {
            template = '<noinclude>' + template + '</noinclude>';
        }

        // Prepare parameters
        var params = {
            action: 'edit',
            title: pageTitle,
            prependtext: template + '\n',
            summary: editSummary,
            watchlist: window.AjaxDeleteWatchReportedPage ? 'watch' : 'nochange',
            nocreate: true
        };

        // Perform an API request to edit the page
        // and resolve the promise on success.
        api.postWithEditToken(params).done(function () {
            deferred.resolve();
        }).fail(function (code, result) {
            var errorMessage = api.getErrorMessage(result).text();
            return deferred.reject(new OO.ui.Error(
                MSG.cantPrependTemplate.replace('$1', template) + '\n' + errorMessage,
                { recoverable: false }
            ));
        });

        return deferred.promise();
    }

    /**
     * Embeds the deletion request subpage onto the lobby.
     * 
     * @param {string} lobbyPage The lobby page, where to embed the request
     * @param {string} requestPageTitle Full title of the subpage with the request
     * @returns {jQuery.Promise<void>} A promise representing the API operation.
     */
    function addRequestToLobby(lobbyPage, requestPageTitle) {
        var deferred = $.Deferred();

        var appendedText = '{{' + requestPageTitle + '}}';
        appendTextToMarker(
            lobbyPage,
            MSG.dnuLobbyMarker,
            appendedText,
            MSG.dnuLobbyEditSummary.replace('$1', requestPageTitle)
        ).then(function () {
            deferred.resolve();
        }).fail(function (reason) {
            deferred.reject(new OO.ui.Error(
                MSG.cantAddToLobby
                    .replace('$1', lobbyPage)
                    .replace('$2', '{{' + requestPageTitle + '}}')
                + '\n' + reason,
                { recoverable: false }
            ));
        });

        return deferred.promise();
    }

    /**
     * Creates a poster objects for all talk pages of the users
     * 
     * @param {string[]} userNames Names of the users to notify
     * @returns {jQuery.Promise<mw.messagePoster.MessagePoster[]>} A promise resolving to a list of message posters
     */
    function createMessagePosters(userNames) {
        var deferred = $.Deferred();

        var userTalk = mw.config.get('wgFormattedNamespaces')[3] + ':';
        var talkPages = userNames.map(function (user) {
            return userTalk + user;
        });

        var posters = [];
        var prevPosterPromise = $.Deferred().resolve().promise();
        talkPages.forEach(function (talkPage) {
            var title = new mw.Title(talkPage);
            prevPosterPromise = prevPosterPromise.then(function () {
                return mw.messagePoster.factory.create(title)
                    .then(function (poster) {
                        posters.push(poster);
                    });
            });
        });

        prevPosterPromise.then(function () {
            deferred.resolve(posters);
        });

        return deferred.promise();
    }

    /**
     * Leaves a message on the users' talk pages
     * 
     * @param {mw.message.MessagePoster[]} messagePosters MessagePosters to user talk pages
     * @param {string} talkMessage The message to leave on the every talk page
     * @returns {jQuery.Promise<void>} A promise representing the operation.
     */
    function notifyUsers(messagePosters, talkMessage) {
        var deferred = $.Deferred();

        var pageTitle = mw.config.get('wgPageName').replaceAll('_', ' ');
        var sectionTitle = MSG.dnuTalkSectionTitle.replace('$1', pageTitle);

        // Post the messages sequentially, one after another
        var lastPromise = $.Deferred().resolve().promise();
        messagePosters.forEach(function (poster) {
            lastPromise = lastPromise.then(function () {
                return poster.post(sectionTitle, talkMessage, {
                    tags: 'AjaxQuickDelete'
                });
            });
        });

        // Resolve our promise only after all edits to the talk pages are finished
        lastPromise.then(function () {
            deferred.resolve();
        }).fail(function (reason) {
            deferred.reject(new OO.ui.Error(
                MSG.cantNotifyUsers + '\n' + reason,
                { recoverable: false }
            ));
        });

        return deferred.promise();
    }

    /**
     * Adds a notification to the wikiproject pages
     * 
     * @param {string[]} wikiprojects List of the wikiproject pages to leave the message on
     * @param {string} message The message to leave
     * @returns {jQuery.Promise<void>} A promise representing the API operation.
     */
    function notifyWikiprojects(wikiprojects, message) {
        var deferred = $.Deferred();

        var pageTitle = mw.config.get('wgPageName').replaceAll('_', ' ');
        var editSummary = MSG.dnuTalkSummary.replace('$1', pageTitle);

        // Notify the wikiprojects asynchronously, at the same time
        var promises = [];
        for(var i = 0; i < wikiprojects.length; i++) {
            promises.push(appendTextToMarker(
                wikiprojects[i],
                MSG.dnuLobbyMarker,
                message,
                editSummary
            ));
        }

        // Resolve our promise only after all edits are finished
        $.when.apply($, promises).then(function () {
            deferred.resolve();
        }).fail(function (reason) {
            deferred.reject(new OO.ui.Error(
                MSG.cantNotifyWikiprojects + '\n' + reason,
                { recoverable: false }
            ));
        });

        return deferred.promise();
    }

    /**
     * Finds a specific marker on the page and appends to it
     * 
     * @param {string} pageTitle The page to edit
     * @param {string} marker The marker to be found
     * @param {string} textToAppend String that will be inserted just after the marker
     * @param {string} editSummary Summary of the edit
     * @returns {jQuery.Promise}
     */
    function appendTextToMarker(pageTitle, marker, textToAppend, editSummary) {
        var deferred = $.Deferred();

        api.edit(pageTitle, function (revision) {
            // Replace the marker with itself + the new text
            var newContent = revision.content.replace(
                marker,
                marker + '\n' + textToAppend
            );

            // Check if the marker was found; if not, append the text to the end
            if(newContent == revision.content) newContent += '\n' + textToAppend;

            return {
                text: newContent,
                summary: editSummary
            };
        }).then(function () {
            deferred.resolve();
        }).fail(function (code, result) {
            var errorMessage = api.getErrorMessage(result).text();
            return deferred.reject(
                MSG.apiFail.replace('$1', errorMessage)
            );
        });

        return deferred.promise();
    }


    /**
     * Does a MediaWiki API request.
     *
     * Uses POST requests for everything for simplicity.
     *
     * @param {object} params Query parameters.
     * @returns {jQuery.Promise} Promise resolving to the API response
     */
    function doAPICall(params) {
        var deferred = $.Deferred();

        params.errorformat = 'html';
        params.errorlang = mw.config.get('wgUserLanguage');
        params.errorsuselocal = true;

        api.post(params).done(function (result, jqXHR) {
            deferred.resolve(result);
        }).fail(function (code, result) {
            var errorMessage = api.getErrorMessage(result).text();
            return deferred.reject(
                MSG.apiFail.replace('$1', errorMessage)
            );
        });

        return deferred.promise();
    }

    function reloadPage() {
        var pageTitle = mw.config.get('wgPageName');
        var title = encodeURIComponent(pageTitle).
            replace(/%3A/g, ':').replace(/%20/g, '_').
            replace(/\(/g, '%28').replace(/\)/g, '%29').
            replace(/%2F/g, '/');

        location.href = mw.config.get('wgServer') + mw.config.get('wgArticlePath').replace("$1", title);
    }
    
    // Install the gadget
    install();
})();
// </nowiki>
/**
 * @typedef {{
 *      content: OO.ui.PanelLayout,
 *      proceed: (dialog: OO.ui.ProcessDialog) => OO.ui.Process,
 *      getSynchronizedState: () => {reason: string},
 *      applySynchronizedState: (state: {reason: string} | null) => void
 * }} View
 */