Nothing Special   »   [go: up one dir, main page]

Note: After saving, you have to bypass your browser's cache to see the changes. Google Chrome, Firefox, Microsoft Edge and Safari: Hold down the ⇧ Shift key and click the Reload toolbar button. For details and instructions about other browsers, see Wikipedia:Bypass your cache.
// <nowiki>

// todo: make counter inline, remove progresss and progressElement from editPAge(), more dynamic reatelimit wait.
// counter semi inline; adjust align in createProgressBar()
// Function to wipe the text content of the page inside #bodyContent

// update normalise function for CfD - use mw.Title() -- this will solve bugs like title input as ":foo" 



function capitalise(s) {
    return s[0].toUpperCase() + s.slice(1);
}

var XFDconfig = {
    "CFD": {
        "title": "Mass CfD",
        "placeholderDiscussionLink": 'Wikipedia:Categories for discussion/Log/2023 July 23#Category:Archaeological cultures by ethnic group',
        "placeholderNominationTitle": 'Archaeological cultures by ethnic group',
        "placeholderRationale": '[[WP:DEFINING|Non-defining]] category.',
        "pageDemoText": "{{subst:Cfd|Category:Bishops}}",
        "discussionLinkRegex": /^Wikipedia:Categories for discussion\/Log\/\d\d\d\d \w+ \d\d?#(.+)$/,
        "nominationReplacement": [/==== ?NEW NOMINATIONS ?====\s*(?:<!-- ?Please add the newest nominations below this line ?-->)?/, '$&\n\n${nominationText}'],
        "userNotificationTemplate": 'Cfd mass notice',
        "baseDiscussionPage": 'Wikipedia:Categories for discussion/Log/',
        "normaliseFunction": (title) => { return 'Category:' + capitalise(title.replace(/^ *[Cc]ategory:/, '').trim()); },
        "actions": {
            "Delete": {
                'prepend': '{{subst:Cfd|${sectionName}}}',
                'action': 'deleting'
            },
            "Rename": {
                'prepend': '{{subst:Cfr|$1|${sectionName}}}',
                'action': 'renaming'
            },
            "Merge": {
                'prepend': '{{subst:Cfm|$1|${sectionName}}}',
                'action': 'merging'
            },
            "Split": {
                'prepend': '{{subst:Cfs|$1|$2|${sectionName}}}',
                'action': 'splitting'
            },
            "Listify": {
                'prepend': '{{subst:Cfl|$1|${sectionName}}}',
                'action': 'listifying'
            },
            "Custom": {
                'prepend': '{{subst:Cfd|type=|${sectionName}}}',
                'action': ''
            },
        },
        "displayTemplates": [{
            data: 'lc',
            label: 'Category link with extra links – {{lc}}'
        },
        {
            data: 'clc',
            label: 'Category link with count – {{clc}}'
        },
        {
            data: 'cl',
            label: 'Plain category link – {{cl}}'
        }],


    },
    "RFD": {
        "title": "Mass RfD",
        "placeholderDiscussionLink": 'Wikipedia:Redirects for discussion/Log/2024 May 13#Knightfall (comics)',
        "placeholderNominationTitle": 'Knightfall',
        "placeholderRationale": 'No mention of "Knightfall" in the target article.',
        "pageDemoText": "",
        "discussionLinkRegex": /^Wikipedia:Redirects for discussion\/Log\/\d\d\d\d \w+ \d\d?#(.+)$/,
        "nominationReplacement": [/<!-- ?Add new entries directly below this line\. ?-->/, '$&\n${nominationText}\n'],
        "userNotificationTemplate": 'Rfd mass notice',
        "baseDiscussionPage": 'Wikipedia:Redirects for discussion/Log/',
        "normaliseFunction": (title) => { return new mw.Title(title).getPrefixedText() },
        "actions":
        {
            'prepend': '{{subst:rfd|${sectionName}|content=\n${pageText}\n}}'
        },
        "displayTemplate": "{{subst:rfd2|multi=yes|redirect=${pageName}|target=${redirectTarget}}}"
    }
}
const match = /Special:Mass(\w+)/.exec(mw.config.get('wgPageName'))
const XFD = match ? match[1].toUpperCase() : false
const config = XFDconfig[XFD]

function wipePageContent() {
    var bodyContent = $('#bodyContent');
    if (bodyContent) {
        bodyContent.empty();
    }
    var header = $('#firstHeading');
    if (header) {
        header.text(config.title);
    }
    $('title').text(`${config.title} - Wikipedia`);
}

function createProgressElement() {
    var progressContainer = new OO.ui.PanelLayout({
        padded: true,
        expanded: false,
        classes: ['sticky-container']
    });
    return progressContainer;
}

function makeInfoPopup(info) {
    var infoPopup = new OO.ui.PopupButtonWidget({
        icon: 'info',
        framed: false,
        label: 'More information',
        invisibleLabel: true,
        popup: {
            head: true,
            icon: 'infoFilled',
            label: 'More information',
            $content: $(`<p>${info}</p>`),
            padded: true,
            align: 'force-left',
            autoFlip: false
        }
    });
    return infoPopup;
}

function makeCategoryTemplateDropdown(label) {
    var dropdown = new OO.ui.DropdownInputWidget({
        required: true,
        options: config.displayTemplates
    });
    var fieldlayout = new OO.ui.FieldLayout(
        dropdown,
        {
            label,
            align: 'inline',
            classes: ['newnomonly'],
        }
    );
    return { container: fieldlayout, dropdown };
}

function createTitleAndInputFieldWithLabel(label, placeholder, classes = []) {
    var input = new OO.ui.TextInputWidget({
        placeholder
    });


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

    fieldset.addItems([
        new OO.ui.FieldLayout(input, {
            label
        }),
    ]);

    return {
        container: fieldset,
        inputField: input,
    };
}
// Function to create a title and an input field
function createTitleAndInputField(title, placeholder, info = false) {
    var container = new OO.ui.PanelLayout({
        expanded: false
    });

    var titleLabel = new OO.ui.LabelWidget({
        label: $(`<span>${title}</span>`)
    });

    var infoPopup = makeInfoPopup(info);
    var inputField = new OO.ui.MultilineTextInputWidget({
        placeholder,
        indicator: 'required',
        rows: 10,
        autosize: true
    });
    if (info) container.$element.append(titleLabel.$element, infoPopup.$element, inputField.$element);
    else container.$element.append(titleLabel.$element, inputField.$element);
    return {
        titleLabel,
        inputField,
        container,
        infoPopup,
    };
}

// Function to create a title and an input field
function createTitleAndSingleInputField(title, placeholder) {
    var container = new OO.ui.PanelLayout({
        expanded: false
    });

    var titleLabel = new OO.ui.LabelWidget({
        label: title
    });

    var inputField = new OO.ui.TextInputWidget({
        placeholder,
        indicator: 'required'
    });

    container.$element.append(titleLabel.$element, inputField.$element);

    return {
        titleLabel,
        inputField,
        container
    };
}

function createStartButton() {
    var button = new OO.ui.ButtonWidget({
        label: 'Start',
        flags: ['primary', 'progressive']
    });

    return button;
}

function createAbortButton() {
    var button = new OO.ui.ButtonWidget({
        label: 'Abort',
        flags: ['primary', 'destructive']
    });

    return button;
}

function createRemoveBatchButton() {
    var button = new OO.ui.ButtonWidget({
        label: 'Remove',
        icon: 'close',
        title: 'Remove',
        classes: [
            'remove-batch-button'
        ],
        flags: [
            'destructive'
        ]
    });
    return button;
}

function createNominationToggle() {

    var newNomToggle = new OO.ui.ButtonOptionWidget({
        data: 'new',
        label: 'New nomination',
        selected: true
    });
    var oldNomToggle = new OO.ui.ButtonOptionWidget({
        data: 'old',
        label: 'Old nomination',
    });

    var toggle = new OO.ui.ButtonSelectWidget({
        items: [
            newNomToggle,
            oldNomToggle
        ]
    });
    return {
        toggle,
        newNomToggle,
        oldNomToggle,
    };
}


function createMessageElement() {
    var messageElement = new OO.ui.MessageWidget({
        type: 'progress',
        inline: true,
        progressType: 'infinite'
    });
    return messageElement;
}

function createWarningMessage() {
    var warningMessage = new OO.ui.MessageWidget({
        type: 'warning',
        style: 'background-color: yellow;'
    });
    return warningMessage;
}

function createCompletedElement() {
    var messageElement = new OO.ui.MessageWidget({
        type: 'success',
    });
    return messageElement;
}

function createDoingElement() {
    var messageElement = new OO.ui.MessageWidget({
        type: 'info',
    });
    return messageElement;
}

function createAbortMessage() { // pretty much a duplicate of ratelimitMessage
    var abortMessage = new OO.ui.MessageWidget({
        type: 'warning',
    });
    return abortMessage;
}

function createErrorMessage(text) {
    var errorMessage = new OO.ui.MessageWidget({
        type: 'error',
    });
    errorMessage.setLabel(text);
    return errorMessage;
}

function createNominationErrorMessage() { // pretty much a duplicate of ratelimitMessage
    return createErrorMessage('Could not detect where to add new nomination.')
}

function createFieldset(headingLabel) {
    var fieldset = new OO.ui.FieldsetLayout({
        label: headingLabel,
    });
    return fieldset;
}

function createCheckboxWithLabel(label) {
    var checkbox = new OO.ui.CheckboxInputWidget({
        value: 'a',
        selected: true,
        label: "Foo",
        data: "foo"
    });
    var fieldlayout = new OO.ui.FieldLayout(
        checkbox,
        {
            label,
            align: 'inline',
            selected: true
        }
    );
    return {
        fieldlayout,
        checkbox
    };
}
function createMenuOptionWidget(data, label) {
    var menuOptionWidget = new OO.ui.MenuOptionWidget({
        data,
        label
    });
    return menuOptionWidget;
}
function createActionDropdown() {
    var items = Object.keys(config.actions)
        .map(action => [action, action]) // [label, data]
        .map(action => createMenuOptionWidget(...action));

    var dropdown = new OO.ui.DropdownWidget({
        label: 'Mass action',
        menu: {
            items
        }
    });
    return { dropdown };
}

function createMultiOptionButton() {
    var button = new OO.ui.ButtonWidget({
        label: 'Additional action',
        icon: 'add',
        flags: [
            'progressive'
        ]
    });
    return button;
}

function sleep(ms) {
    return new Promise(resolve => setTimeout(resolve, ms));
}

function makeLink(title) {
    return `<a href="/wiki/${title}" target="_blank">${title}</a>`;
}

function getDateDifference(date1) {
    const currentDate = new Date();
    // now
    let date2 = `${currentDate.getUTCFullYear()} ${currentDate.toLocaleString('en', { month: 'long', timeZone: 'UTC' })} ${currentDate.getUTCDate()}`

    // Parse the dates
    const parseDate = (dateString) => {
        const [year, month, day] = dateString.split(' ');
        return new Date(`${year}-${month}-${day}`);
    };

    const d1 = parseDate(date1);
    const d2 = parseDate(date2);

    // Calculate the time difference in milliseconds
    const timeDifference = Math.abs(d2 - d1);

    // Convert the time difference from milliseconds to days
    const dayDifference = Math.ceil(timeDifference / (1000 * 60 * 60 * 24));

    return dayDifference;
}

function deepCopy(obj) {
    if (obj === null || typeof obj !== 'object') {
        return obj;
    }

    if (obj instanceof OO.ui.Element) {
        return obj;
    }

    if (Array.isArray(obj)) {
        const copy = [];
        for (let i = 0; i < obj.length; i++) {
            copy[i] = deepCopy(obj[i]);
        }
        return copy;
    }

    const copy = {};
    for (const key in obj) {
        if (obj.hasOwnProperty(key)) {
            copy[key] = deepCopy(obj[key]);
        }
    }
    return copy;
}

function parseHTML(html) {
    // Create a temporary div to parse the HTML
    var tempDiv = $('<div>').html(html);

    // Find all li elements
    var liElements = tempDiv.find('li');

    // Array to store extracted hrefs
    var hrefs = [];

    let existinghrefRegexp = /^https:\/\/en\.wikipedia.org\/wiki\/([^?&]+?)$/;
    let nonexistinghrefRegexp = /^https:\/\/en\.wikipedia\.org\/w\/index\.php\?title=([^&?]+?)&action=edit&redlink=1$/;

    // Iterate through each li element
    liElements.each(function () {
        // Find all anchor (a) elements within the current li
        let hrefline = [];
        var anchorElements = $(this).find('a');

        // Extract href attribute from each anchor element
        anchorElements.each(function () {
            var href = $(this).attr('href');
            if (href) {
                var existingMatch = existinghrefRegexp.exec(href);
                var nonexistingMatch = nonexistinghrefRegexp.exec(href);
                let page;
                if (existingMatch) page = new mw.Title(existingMatch[1]);
                if (nonexistingMatch) page = new mw.Title(nonexistingMatch[1]);
                if (page && page.getNamespaceId() > -1 && !page.isTalkPage()) {
                    hrefline.push(page.getPrefixedText());
                }


            }
        });
        hrefs.push(hrefline);
    });

    return hrefs;
}

function handlepaste(widget, e) {
    var types, pastedData, parsedData;
    // Browsers that support the 'text/html' type in the Clipboard API (Chrome, Firefox 22+)
    if (e && e.clipboardData && e.clipboardData.types && e.clipboardData.getData) {
        // Check for 'text/html' in types list
        types = e.clipboardData.types;
        if (((types instanceof DOMStringList) && types.contains("text/html")) ||
            ($.inArray && $.inArray('text/html', types) !== -1)) {
            // Extract data and pass it to callback
            pastedData = e.clipboardData.getData('text/html');

            parsedData = parseHTML(pastedData);

            // Check if it's an empty array
            if (!parsedData || parsedData.length === 0) {
                // Allow the paste event to propagate for plain text or empty array
                return true;
            }
            let confirmed = confirm('You have pasted formatted text. Do you want this to be converted into wikitext?');
            if (!confirmed) return true;
            processPaste(widget, pastedData);

            // Stop the data from actually being pasted
            e.stopPropagation();
            e.preventDefault();
            return false;
        }
    }

    // Allow the paste event to propagate for plain text
    return true;
}

function waitForPastedData(widget, savedContent) {
    // If data has been processed by the browser, process it
    if (widget.getValue() !== savedContent) {
        // Retrieve pasted content via widget's getValue()
        var pastedData = widget.getValue();

        // Restore saved content
        widget.setValue(savedContent);

        // Call callback
        processPaste(widget, pastedData);
    }
    // Else wait 20ms and try again
    else {
        setTimeout(function () {
            waitForPastedData(widget, savedContent);
        }, 20);
    }
}

function processPaste(widget, pastedData) {
    // Parse the HTML
    var parsedArray = parseHTML(pastedData);
    let stringOutput = '';
    for (const pages of parsedArray) {
        stringOutput += pages.join('|') + '\n';
    }
    widget.insertContent(stringOutput);
}


function getWikitext(pageTitle) {
    var api = new mw.Api();

    var requestData = {
        "action": "query",
        "format": "json",
        "prop": "revisions",
        "titles": pageTitle,
        "formatversion": "2",
        "rvprop": "content",
        "rvlimit": "1",
    };
    return api.get(requestData).then(function (data) {
        var pages = data.query.pages;
        return pages[0].revisions[0].content; // Return the wikitext
    }).catch(function (error) {
        console.error('Error fetching wikitext:', error);
    });
}

// function to revert edits - this is hacky, and potentially unreliable
function revertEdits() {
    var revertAllCount = 0;
    var revertElements = $('.massxfdundo');
    if (!revertElements.length) {
        $('#massxfdrevertlink').replaceWith('Reverts done.');
    } else {
        $('#massxfdrevertlink').replaceWith('<span><span id="revertall-text">Reverting...</span> (<span id="revertall-done">0</span> / <span id="revertall-total">' + revertElements.length + '</span> done)</span>');

        revertElements.each(function (index, element) {
            element = $(element); // jQuery-ify
            var title = element.attr('data-title');
            var revid = element.attr('data-revid');
            revertEdit(title, revid)
                .then(function () {
                    element.text('. Reverted.');
                    revertAllCount++;
                    $('#revertall-done').text(revertAllCount);
                }).catch(function () {
                    element.html('. Revert failed. <a href="/wiki/Special:Diff/' + revid + '">Click here</a> to view the diff.');
                });
        }).promise().done(function () {
            $('#revertall-text').text('Reverts done.');
        });
    }
}

function revertEdit(title, revid, retry = false) {
    var api = new mw.Api();


    if (retry) {
        sleep(1000);
    }

    var requestData = {
        action: 'edit',
        title,
        undo: revid,
        format: 'json'
    };
    return new Promise(function (resolve, reject) {
        api.postWithEditToken(requestData).then(function (data) {
            if (data.edit && data.edit.result === 'Success') {
                resolve(true);
            } else {
                console.error('Error occurred while undoing edit:', data);
                reject();
            }
        }).catch(function (error) {
            console.error('Error occurred while undoing edit:', error); // handle: editconflict, ratelimit (retry)
            if (error == 'editconflict') {
                resolve(revertEdit(title, revid, retry = true));
            } else if (error == 'ratelimited') {
                setTimeout(function () { // wait a minute
                    resolve(revertEdit(title, revid, retry = true));
                }, 60000);
            } else {
                reject();
            }
        });
    });
}

function getRedirectData(titles) {
    var api = new mw.Api();
    return api.get({
        action: 'query',
        titles,
        redirects: 1,
        format: 'json'
    }).then(function (data) {
        return data.query;
    }).catch(function (error) {
        console.error('Error occurred while fetching page author:', error);
        return false;
    });
}

function getUserData(titles) {
    var api = new mw.Api();
    return api.get({
        action: 'query',
        list: 'users',
        ususers: titles,
        usprop: 'blockinfo|groups', // blockinfo - check if indeffed, groups - check if bot
        format: 'json'
    }).then(function (data) {
        return data.query.users;
    }).catch(function (error) {
        console.error('Error occurred while fetching page author:', error);
        return false;
    });
}

function getPageAuthor(title) {
    var api = new mw.Api();
    return api.get({
        action: 'query',
        prop: 'revisions',
        titles: title,
        rvprop: 'user',
        rvdir: 'newer', // Sort the revisions in ascending order (oldest first)
        rvlimit: 1,
        format: 'json'
    }).then(function (data) {
        var pages = data.query.pages;
        var pageId = Object.keys(pages)[0];
        var revisions = pages[pageId].revisions;
        if (revisions && revisions.length > 0) {

            return revisions[0].user;
        } else {
            return false;
        }
    }).catch(function (error) {
        console.error('Error occurred while fetching page author:', error);
        return false;
    });
}


// Function to create a list of page authors and filter duplicates
async function createAuthorList(titles) {
    var authorList = [];
    var promises = titles.map(function (title) {
        return getPageAuthor(title);
    });
    try {
        const authors = await Promise.all(promises);
        let queryBatchSize = 50;
        let authorTitles = authors.filter(Boolean).map(author => author.replace(/ /g, '_')); // Replace spaces with underscores, remove false values
        let filteredAuthorList = [];
        for (let i = 0; i < authorTitles.length; i += queryBatchSize) {
            let batch = authorTitles.slice(i, i + queryBatchSize);
            let batchTitles = batch.join('|');

            await getUserData(batchTitles)
                .then(response => {
                    response.forEach(user => {
                        if (window.debuggingMode) console.log(user);
                        if (user
                            && (!user.blockexpiry || user.blockexpiry !== "infinite" || 'blockpartial' in user)
                            && !user.groups?.includes('bot')
                            && !filteredAuthorList.includes('User talk:' + user.name))
                            filteredAuthorList.push('User talk:' + user.name);
                    });

                })
                .catch(error => {
                    console.error("Error querying API:", error);
                });
        }
        return filteredAuthorList;
    } catch (error_1) {
        console.error('Error occurred while creating author list:', error_1);
        return authorList;
    }
}

// Function to create a list of page authors and filter duplicates
async function createRedirectTargetsList(titles) {
    try {
        let queryBatchSize = 50;
        let redirectTitles = titles.map(title => title.replace(/ /g, '_')); // Replace spaces with underscores
        let redirectTargets = {};
        let nonredirects = [];
        for (let i = 0; i < redirectTitles.length; i += queryBatchSize) {
            let batch = redirectTitles.slice(i, i + queryBatchSize);
            let batchTitles = batch.join('|');

            await getRedirectData(batchTitles)
                .then(data => {

                    if ('redirects' in data) {
                        data.redirects.forEach(redirect => {
                            redirectTargets[redirect.from] = redirect.to
                        });
                        let redirects = new Set(data.redirects.map(r => r.to))
                        let pages = new Set(Object.values(data.pages).map(p => p.title));
                        nonredirects.push(...[...pages].filter(x => !redirects.has(x)))
                    } else {
                        nonredirects.push(...Object.values(data.pages).map(p => p.title))
                    }

                })
                .catch(error => {
                    console.error("Error querying API:", error);
                });
        }
        return [redirectTargets, nonredirects];
    } catch (error_1) {
        console.error('Error occurred while fetching redirect targets', error_1);
        return [redirectTargets, nonredirects];
    }
}

function editPage(options) {
    const localOptions = deepCopy(options);
    localOptions.text = localOptions.textToModify;
    const api = new mw.Api();
    const messageElement = createMessageElement();

    messageElement.setLabel((localOptions.retry)
        ? $('<span>').text('Retrying ').append($(makeLink(localOptions.title)))
        : $('<span>').text('Editing ').append($(makeLink(localOptions.title))));

    localOptions.progressElement.$element.append(messageElement.$element);
    const container = $('.sticky-container');
    container.scrollTop(container.prop("scrollHeight"));

    if (localOptions.retry) {
        sleep(1000);
    }

    const requestData = {
        action: 'edit',
        title: window.debuggingMode ? 'User:Qwerfjkl/sandbox/51' : localOptions.title,
        summary: localOptions.summary,
        format: 'json'
    };

    if (localOptions.type === 'prepend') {
        requestData.nocreate = 1;
        const targets = localOptions.titlesDict[localOptions.title];

        for (let i = 0; i < targets.length; i++) {
            const placeholder = '$' + (i + 1);
            localOptions.text = localOptions.text.replace(placeholder, targets[i]);
        }
        localOptions.text = localOptions.text.replace(/\$\d/g, '');
        requestData.prependtext = localOptions.text.trim() + '\n\n';
    } else if (localOptions.type === 'append') {
        requestData.appendtext = '\n\n' + localOptions.text.trim();
    } else if (localOptions.type === 'text') {
        requestData.text = localOptions.text;
    }

    return new Promise((resolve, reject) => {
        if (window.abortEdits) {
            messageElement.toggle(false);
            resolve();
            return;
        }

        api.postWithEditToken(requestData)
            .then((data) => {
                if (data.edit && data.edit.result === 'Success') {
                    messageElement.setType('success');
                    messageElement.setLabel($('<span>' + makeLink(localOptions.title) + ' edited successfully</span><span class="massxfdundo" data-revid="' + data.edit.newrevid + '" data-title="' + localOptions.title + '"></span>'));
                    resolve();
                } else {
                    handleError('Error occurred while editing', data, localOptions, messageElement, resolve, reject);
                }
            })
            .catch((error) => handleError('Error occurred while editing', error, localOptions, messageElement, resolve, reject));
    });
}

function handleError(msg, error, options, messageElement, resolve, reject) {
    messageElement.setType('error');
    messageElement.setLabel($('<span>' + msg + ' ' + makeLink(options.title) + ': ' + error + '</span>'));
    console.error(msg + ' page:', error);

    if (error === 'editconflict') {
        editPage(deepCopy(options)).then(resolve);
    } else if (error === 'ratelimited') {
        options.progress.setDisabled(true);
        handleRateLimitError(options.ratelimitMessage).then(() => {
            options.progress.setDisabled(false);
            editPage(deepCopy(options)).then(resolve);
        });
    } else {
        reject();
    }
}


// global scope - needed to syncronise ratelimits
var massXFDratelimitPromise = null;
// Function to handle rate limit errors
function handleRateLimitError(ratelimitMessage) {
    var modify = !(ratelimitMessage.isVisible()); // only do something if the element hasn't already been shown

    if (massXFDratelimitPromise !== null) {
        return massXFDratelimitPromise;
    }

    massXFDratelimitPromise = new Promise(function (resolve) {
        var remainingSeconds = 60;
        var secondsToWait = remainingSeconds * 1000;
        console.log('Rate limit reached. Waiting for ' + remainingSeconds + ' seconds...');

        ratelimitMessage.setType('warning');
        ratelimitMessage.setLabel('Rate limit reached. Waiting for ' + remainingSeconds + ' seconds...');
        ratelimitMessage.toggle(true);

        var countdownInterval = setInterval(function () {
            remainingSeconds--;
            if (modify) {
                ratelimitMessage.setLabel('Rate limit reached. Waiting for ' + remainingSeconds + ' second' + ((remainingSeconds === 1) ? '' : 's') + '...');
            }

            if (remainingSeconds <= 0 || window.abortEdits) {
                clearInterval(countdownInterval);
                massXFDratelimitPromise = null; // reset
                ratelimitMessage.toggle(false);
                resolve();
            }
        }, 1000);

        // Use setTimeout to ensure the promise is resolved even if the countdown is not reached
        setTimeout(function () {
            clearInterval(countdownInterval);
            ratelimitMessage.toggle(false);
            massXFDratelimitPromise = null; // reset
            resolve();
        }, secondsToWait);
    });
    return massXFDratelimitPromise;
}

// Function to show progress visually
function createProgressBar(label) {
    var progressBar = new OO.ui.ProgressBarWidget();
    progressBar.setProgress(0);
    var fieldlayout = new OO.ui.FieldLayout(progressBar, {
        label,
        align: 'inline'
    });
    return {
        progressBar,
        fieldlayout
    };
}


// Main function to execute the script
async function runMassXFD() {

    Object.keys(XFDconfig).forEach(function (XfD) {
        mw.util.addPortletLink('p-tb', mw.util.getUrl(`Special:Mass${XfD}`), `Mass ${XfD}`, `pt-mass${XfD.toLowerCase()}`, `Create a mass ${XfD} nomination`);
    })


    if (XFD && config) {
        // Load the required modules
        mw.loader.using('oojs-ui').done(function () {
            wipePageContent();
            if (!window.debuggingMode) { // annoying when reloading for debugging
                onbeforeunload = function () {
                    return "Closing this tab will cause you to lose all progress.";
                };
            }
            elementsToDisable = [];
            var bodyContent = $('#bodyContent');

            mw.util.addCSS(`.sticky-container { 
                bottom: 0;
                width: 100%;
                max-height: 600px; 
                overflow-y: auto;
            }`); // should probably be styled directly on the element than via the stylesheet
            var nominationToggleObj = createNominationToggle();
            var nominationToggle = nominationToggleObj.toggle;

            bodyContent.append(nominationToggle.$element);
            elementsToDisable.push(nominationToggle);

            var rationaleObj = createTitleAndInputField('Rationale:', config.placeholderRationale);
            var rationaleContainer = rationaleObj.container;
            var rationaleInputField = rationaleObj.inputField;
            elementsToDisable.push(rationaleInputField);

            var nominationToggleOld = nominationToggleObj.oldNomToggle;
            var nominationToggleNew = nominationToggleObj.newNomToggle;

            var discussionLinkObj = createTitleAndSingleInputField('Discussion link', config.placeholderDiscussionLink);
            var discussionLinkContainer = discussionLinkObj.container;
            var discussionLinkInputField = discussionLinkObj.inputField;
            elementsToDisable.push(discussionLinkInputField);

            var newNomHeaderObj = createTitleAndSingleInputField('Nomination title', config.placeholderNominationTitle);
            var newNomHeaderContainer = newNomHeaderObj.container;
            var newNomHeaderInputField = newNomHeaderObj.inputField;
            elementsToDisable.push(newNomHeaderInputField);

            bodyContent.append(discussionLinkContainer.$element);
            bodyContent.append(newNomHeaderContainer.$element, rationaleContainer.$element);
            function displayElements() {
                if (nominationToggleOld.isSelected()) {
                    discussionLinkContainer.$element.show();
                    newNomHeaderContainer.$element.hide();
                    rationaleContainer.$element.hide();
                }
                else if (nominationToggleNew.isSelected()) {
                    discussionLinkContainer.$element.hide();
                    newNomHeaderContainer.$element.show();
                    rationaleContainer.$element.show();

                }
            }
            displayElements();
            nominationToggle.on('select', displayElements);




            function createActionNomination(actionsContainer, first = false) {
                var count = actions.length + 1;
                let actionNominationTitle = XFD === 'CFD' ? 'Action batch #' + count : ''
                var container = createFieldset(actionNominationTitle);
                actionsContainer.append(container.$element);

                var actionDropdownObj = createActionDropdown();
                var dropdown = actionDropdownObj.dropdown;

                elementsToDisable.push(dropdown);
                dropdown.$element.css('max-width', 'fit-content');
                let demoText = config.pageDemoText
                var prependTextObj = createTitleAndInputField('Text to tag the nominated pages with:', demoText, info = 'A dollar sign <code>$</code> followed by a number, such as <code>$1</code>, will be replaced with a target specified in the title field, or if not target is specified, will be removed.');
                var prependTextLabel = prependTextObj.titleLabel;
                var prependTextInfoPopup = prependTextObj.infoPopup;
                var prependTextInputField = prependTextObj.inputField;


                elementsToDisable.push(prependTextInputField);
                var prependTextContainer = new OO.ui.PanelLayout({
                    expanded: false
                });
                var actionObj = createTitleAndInputFieldWithLabel('Action', 'renaming', classes = ['newnomonly']);
                var actionContainer = actionObj.container;
                var actionInputField = actionObj.inputField;
                elementsToDisable.push(actionInputField);
                actionInputField.$element.css('max-width', 'fit-content');
                if (nominationToggleOld.isSelected()) actionContainer.$element.hide(); // make invisible until needed
                prependTextContainer.$element.append(prependTextLabel.$element, prependTextInfoPopup.$element, dropdown.$element, actionContainer.$element, prependTextInputField.$element);

                nominationToggle.on('select', function () {
                    if (nominationToggleOld.isSelected()) {
                        $('.newnomonly').hide();
                        if (discussionLinkInputField.getValue().trim()) discussionLinkInputField.emit('change');
                    }
                    else if (nominationToggleNew.isSelected()) {
                        if (XFD === 'CFD') $('.newnomonly').show();
                        if (newNomHeaderInputField.getValue().trim()) newNomHeaderInputField.emit('change');
                    }
                });

                if (nominationToggleOld.isSelected()) {
                    if (discussionLinkInputField.getValue().match(config.discussionLinkRegex)) {
                        sectionName = discussionLinkInputField.getValue().trim().match(config.discussionLinkRegex)[1];
                    }
                }
                else if (nominationToggleNew.isSelected()) {
                    sectionName = newNomHeaderInputField.getValue().trim();
                }

                // helper function, makes more accurate.
                function replaceOccurence(str, find, replace) {
                    if (XFD === 'CFD') {
                        // last occurence
                        let index = str.lastIndexOf(find);

                        if (index >= 0) {
                            return str.substring(0, index) + replace + str.substring(index + find.length);
                        } else {
                            return str;
                        }
                    } else if (XFD === 'RFD') {
                        if (str.toLowerCase().startsWith('{{subst:rfd|')) {
                            str = str.replace(/\{\{subst:rfd\|/i, '')
                            return '{{subst:rfd|' + str.replace(find, replace)
                        } else {
                            return str.replace(find, replace) // first occurence
                        }
                    }
                }

                var sectionName = sectionName || 'sectionName';
                var oldSectionName = sectionName;
                if (XFD !== 'CFD') {
                    prependTextInputField.setValue(config.actions.prepend.replace('${sectionName}', sectionName))
                    if (XFD === 'RFD') {
                        if (discussionLinkInputField.getValue().match(config.discussionLinkRegex)) {
                            let date = discussionLinkInputField.getValue().trim().match(/^Wikipedia:Redirects for discussion\/Log\/(\d\d\d\d \w+ \d\d)?#.+$/)[1]
                            let difference = getDateDifference(date)
                            if (difference !== 0) {
                                prependTextInputField.setValue(config.actions.prepend.replace('{{subst:rfd|${sectionName}|', `{{subst:rfd|${sectionName}|days=${difference}|`))
                            } // else leave as default above
                        }
                    }
                }
                discussionLinkInputField.on('change', function () {
                    if (discussionLinkInputField.getValue().match(config.discussionLinkRegex)) {
                        oldSectionName = sectionName;
                        sectionName = discussionLinkInputField.getValue().replace(config.discussionLinkRegex, '$1').trim();
                        var text = prependTextInputField.getValue();

                        if (XFD === 'RFD') {
                            const date = discussionLinkInputField.getValue().trim().match(/^Wikipedia:Redirects for discussion\/Log\/(\d\d\d\d \w+ \d\d?)#.+$/)[1]

                            if (/(\| *days *= *)\d+/.test(text)) { // already has days=, update
                                text = text.replace(/(\| *days *= *)\d+/, '$1' + getDateDifference(date))
                                text = replaceOccurence(text, oldSectionName, sectionName);
                            } else {
                                text = replaceOccurence(text, oldSectionName, sectionName + '|days=' + getDateDifference(date));
                            }
                        } else text = replaceOccurence(text, oldSectionName, sectionName);

                        prependTextInputField.setValue(text);

                    }
                });

                newNomHeaderInputField.on('change', function () {
                    if (newNomHeaderInputField.getValue().trim()) {
                        oldSectionName = sectionName;
                        sectionName = newNomHeaderInputField.getValue().trim();
                        var text = prependTextInputField.getValue();
                        text = replaceOccurence(text, oldSectionName, sectionName);
                        prependTextInputField.setValue(text);
                    }
                });

                dropdown.on('labelChange', function () {
                    let actionData = config.actions[dropdown.getLabel()];
                    prependTextInputField.setValue(actionData.prepend.replace('${sectionName}', sectionName));
                    actionInputField.setValue(actionData.action);
                });




                var titleListObj = createTitleAndInputField(`List of titles (one per line${XFD === 'CFD' ? ', <code>Category:</code> prefix is optional' : ''})`, 'Title1\nTitle2\nTitle3', info = 'You can specify targets by adding a pipe <code>|</code> and then the target, e.g. <code>Example|Target1|Target2</code>. These targets can be used in the tagging step.');
                var titleList = titleListObj.container;
                var titleListInputField = titleListObj.inputField;
                var titleListInfoPopup = titleListObj.infoPopup;
                elementsToDisable.push(titleListInputField);
                let handler = handlepaste.bind(this, titleListInputField);
                let textInputElement = titleListInputField.$element.get(0);
                // Modern browsers. Note: 3rd argument is required for Firefox <= 6
                if (textInputElement.addEventListener) {
                    textInputElement.addEventListener('paste', handler, false);
                }
                // IE <= 8
                else {
                    textInputElement.attachEvent('onpaste', handler);
                }


                titleListObj.inputField.$element.on('paste', handlepaste);

                if (XFD !== 'CFD') {
                    // most XfDs don't need multiple actions, they're just delete. so hide unnecessary elements'
                    actionContainer.$element.hide();
                    dropdown.$element.hide();
                    prependTextInfoPopup.$element.hide() // both popups give info about targets which aren't relevant here
                    titleListInfoPopup.$element.hide()
                }


                if (!first && XFD !== 'CFD') {
                    var removeButton = createRemoveBatchButton();
                    elementsToDisable.push(removeButton);
                    removeButton.on('click', function () {
                        container.$element.remove();
                        // filter based on the container element
                        actions = actions.filter(function (item) {
                            return item.container !== container;
                        });
                        // Reset labels
                        for (i = 0; i < actions.length; i++) {
                            actions[i].container.setLabel('Action batch #' + (i + 1));
                            actions[i].label = 'Action batch #' + (i + 1);
                        }
                    });

                    container.addItems([removeButton, prependTextContainer, titleList]);

                } else {
                    container.addItems([prependTextContainer, titleList]);
                }

                return {
                    titleListInputField,
                    prependTextInputField,
                    label: 'Action batch #' + count,
                    container,
                    actionInputField
                };
            }
            var actionsContainer = $('<div />');
            bodyContent.append(actionsContainer);
            var actions = [];
            actions.push(createActionNomination(actionsContainer, first = true));

            var checkboxObj = createCheckboxWithLabel('Notify users?');
            var notifyCheckbox = checkboxObj.checkbox;
            elementsToDisable.push(notifyCheckbox);
            var checkboxFieldlayout = checkboxObj.fieldlayout;
            checkboxFieldlayout.$element.css('margin-bottom', '10px');
            bodyContent.append(checkboxFieldlayout.$element);

            var multiOptionButton = createMultiOptionButton();
            elementsToDisable.push(multiOptionButton);
            multiOptionButton.$element.css('margin-bottom', '10px');
            bodyContent.append(multiOptionButton.$element);
            bodyContent.append('<br />');


            multiOptionButton.on('click', () => {
                actions.push(createActionNomination(actionsContainer));
            });
            if (XFD !== 'CFD') {
                multiOptionButton.$element.hide()
            } else {

                var categoryTemplateDropdownObj = makeCategoryTemplateDropdown('Category template:');
                categoryTemplateDropdownContainer = categoryTemplateDropdownObj.container;
                categoryTemplateDropdown = categoryTemplateDropdownObj.dropdown;
                categoryTemplateDropdown.$element.css(
                    {
                        'display': 'inline-block',
                        'max-width': 'fit-content',
                        'margin-bottom': '10px'
                    }
                );
                elementsToDisable.push(categoryTemplateDropdown);
                if (nominationToggleOld.isSelected()) categoryTemplateDropdownContainer.$element.hide();
                bodyContent.append(categoryTemplateDropdownContainer.$element);
            }

            var startButton = createStartButton();
            elementsToDisable.push(startButton);
            bodyContent.append(startButton.$element);



            startButton.on('click', async function () {

                var isOld = nominationToggleOld.isSelected();
                var isNew = nominationToggleNew.isSelected();
                // First check elements
                var error = false;
                var regex = config.discussionLinkRegex;
                if (isOld) {
                    if (!(discussionLinkInputField.getValue().trim()) || !regex.test(discussionLinkInputField.getValue().trim())) {
                        discussionLinkInputField.setValidityFlag(false);
                        error = true;
                    } else {
                        discussionLinkInputField.setValidityFlag(true);
                    }
                } else if (isNew) {
                    if (!(newNomHeaderInputField.getValue().trim())) {
                        newNomHeaderInputField.setValidityFlag(false);
                        error = true;
                    } else {
                        newNomHeaderInputField.setValidityFlag(true);
                    }

                    if (!(rationaleInputField.getValue().trim())) {
                        rationaleInputField.setValidityFlag(false);
                        error = true;
                    } else {
                        rationaleInputField.setValidityFlag(true);
                    }

                }
                batches = actions.map(function ({ titleListInputField, prependTextInputField, label, actionInputField }) {
                    if (!(prependTextInputField.getValue().trim()) || (XFD === 'RFD' && !prependTextInputField.getValue().includes('${pageText}'))) {
                        prependTextInputField.setValidityFlag(false);
                        error = true;
                    } else {
                        prependTextInputField.setValidityFlag(true);

                    }

                    if (isNew && XFD === 'CFD') {
                        if (!(actionInputField.getValue().trim())) {
                            actionInputField.setValidityFlag(false);
                            error = true;
                        } else {
                            actionInputField.setValidityFlag(true);
                        }
                    }

                    if (!(titleListInputField.getValue().trim())) {
                        titleListInputField.setValidityFlag(false);
                        error = true;
                    } else {
                        titleListInputField.setValidityFlag(true);
                    }

                    // Retreive titles, handle dups
                    var titles = {};
                    var titleList = titleListInputField.getValue().split('\n');
                    function normalise(title) {
                        return config.normaliseFunction(title)
                    }
                    titleList.forEach(function (title) {
                        if (title) {
                            var targets = title.split('|');
                            var newTitle = targets.shift();

                            newTitle = normalise(newTitle);
                            if (!Object.keys(titles).includes(newTitle)) {
                                titles[newTitle] = targets.map(normalise);
                            }
                        }
                    });

                    if (!(Object.keys(titles).length)) {
                        titleListInputField.setValidityFlag(false);
                        error = true;
                    } else {
                        titleListInputField.setValidityFlag(true);
                    }
                    return {
                        titles,
                        prependText: prependTextInputField.getValue().trim(),
                        label,
                        actionInputField
                    };
                });



                if (error) {
                    return;
                }

                for (let element of elementsToDisable) {
                    element.setDisabled(true);
                }


                $('.remove-batch-button').remove();

                var abortButton = createAbortButton();
                bodyContent.append(abortButton.$element);
                window.abortEdits = false; // initialise
                abortButton.on('click', function () {

                    // Set abortEdits flag to true
                    if (confirm('Are you sure you want to abort?')) {
                        abortButton.setDisabled(true);
                        window.abortEdits = true;
                    }
                });
                var allTitles = batches.reduce((allTitles, obj) => {
                    return allTitles.concat(Object.keys(obj.titles));
                }, []);


                if (XFD === 'RFD') {
                    let fetchingRedirectsElement = createDoingElement();
                    fetchingRedirectsElement.setLabel('Fetching redirect targets...')
                    fetchingRedirectsElement.$element.css('margin-top', '16px');
                    bodyContent.append(fetchingRedirectsElement.$element);

                    let fetchedRedirectsElement = createCompletedElement();
                    fetchedRedirectsElement.setLabel('Fetched redirect targets')
                    fetchedRedirectsElement.$element.css('margin-top', '16px');

                    var [redirectTargets, nonredirects] = await createRedirectTargetsList(allTitles);
                    if (window.debuggingMode) console.log(`Redirect targets: ${JSON.stringify(redirectTargets)}`);
                    // console.log(Object.values(redirectTargets).map(title => {
                    //     let page = new mw.Title(title)
                    //     return page.getTalkPage().getPrefixedText()
                    // }))
                    // console.log([... new Set(Object.values(redirectTargets).map(title => {
                    //     let page = new mw.Title(title)
                    //     return page.getTalkPage().getPrefixedText()
                    // }))])
                    // window.batches=batches
                    batches[0].titles = Object.keys(batches[0].titles)
                        .filter(x => !nonredirects.includes(x))
                        .reduce((acc, curr) => {
                        acc[curr] = [];
                        return acc;
                        }, {});



                    if (!Object.keys(redirectTargets).length) {
                        var errorMessageElement = createErrorMessage('None of the titles are redirects, aborting.');
                        bodyContent.append(errorMessageElement.$element);
                        return;
                    }
                    if (nonredirects.length) {
                        let nonredirectsWarningMessage = createWarningMessage();
                        nonredirectsWarningMessage.$element.css({ 'max-height': '20em', 'overflow-y': 'auto' }) // normally shouldn't be needed
                        let nonRedirectsHTML = $('<div>').append($('<span>').text('The following pages were ignored because they are not redirects:'))
                        let $listElement = $('<ul>')
                        nonredirects.forEach(item => {
                            const $listItem = $('<li>').html(makeLink(item));
                            $listElement.append($listItem);
                        });
                        nonRedirectsHTML.append($listElement)
                        nonredirectsWarningMessage.setLabel(nonRedirectsHTML)
                        bodyContent.append(nonredirectsWarningMessage.$element)
                    }

                    fetchingRedirectsElement.$element.hide();
                    bodyContent.append(fetchedRedirectsElement.$element);
                }


                let fetchingAuthorsElement = createDoingElement();
                fetchingAuthorsElement.setLabel('Fetching authors...')
                fetchingAuthorsElement.$element.css('margin-top', '16px');
                bodyContent.append(fetchingAuthorsElement.$element);

                let fetchedAuthorsElement = createCompletedElement();
                fetchedAuthorsElement.setLabel('Fetched authors')
                fetchedAuthorsElement.$element.css('margin-top', '16px');
                let authors;
                if (redirectTargets) {
                    authors = await createAuthorList(Object.keys(redirectTargets));
                } else {
                    authors = await createAuthorList(allTitles);
                }

                fetchingAuthorsElement.$element.hide();
                bodyContent.append(fetchedAuthorsElement.$element);


                async function processContent(options) {
                    function getKeyByValue(object, value) {
                        return Object.keys(object).find(key => object[key] === value);
                    }


                    if (!Array.isArray(options.titles)) {
                        options.titlesDict = options.titles;
                        options.titles = Object.keys(options.titles);
                    } else {
                        options.titlesDict = {};
                    }

                    const fieldset = createFieldset(options.headingLabel);
                    bodyContent.append(fieldset.$element);

                    options.progressElement = createProgressElement();
                    fieldset.addItems([options.progressElement]);

                    options.ratelimitMessage = createWarningMessage();
                    options.ratelimitMessage.toggle(false);
                    fieldset.addItems([options.ratelimitMessage]);

                    const progressObj = createProgressBar(`(0 / ${options.titles.length}, 0 errors)`);
                    options.progress = progressObj.progressBar;
                    const progressContainer = progressObj.fieldlayout;
                    options.progress.$element.css('margin-top', '5px');
                    options.progress.pushPending();
                    fieldset.addItems([progressContainer]);

                    let resolvedCount = 0;
                    let rejectedCount = 0;

                    function updateCounter() {
                        progressContainer.setLabel(`(${resolvedCount} / ${options.titles.length}, ${rejectedCount} errors)`);
                    }

                    function updateProgress() {
                        const percentage = (resolvedCount + rejectedCount) / options.titles.length * 100;
                        options.progress.setProgress(percentage);
                    }

                    function trackPromise(promise) {
                        return new Promise((resolve) => {
                            promise
                                .then(value => {
                                    resolvedCount++;
                                    updateCounter();
                                    updateProgress();
                                    resolve(value);
                                })
                                .catch(error => {
                                    rejectedCount++;
                                    updateCounter();
                                    updateProgress();
                                    resolve(error);
                                });
                        });
                    }

                    const promises = [];
                    for (const title of options.titles) {
                        let data = deepCopy(options);
                        if (XFD === 'RFD' && data.type === 'prepend') {
                            const text = await getWikitext(title);
                            data.textToModify = data.textToModify.replace('${pageText}', text);
                            data.type = 'text';
                        }

                        if (data.id === 'rfd-notify-target') {
                            // ${redirectTitle} is a placeholder for the redirect being nominated
                            // this code needs a more intelligent way of checking which redirect was tagged.
                            data.textToModify = data.textToModify.replace('${redirectTitle}', getKeyByValue(redirectTargets, new mw.Title(title).getPrefixedText()) ?? getKeyByValue(redirectTargets, new mw.Title(title).getSubjectPage().getPrefixedText()) ?? "");
                        }

                        data.title = title;

                        const promise = editPage(data);
                        promises.push(trackPromise(promise));

                        if (!window.abortEdits) await sleep(100); // space out calls - not needed if they're being rejected
                        await massXFDratelimitPromise; // stop if ratelimit reached (global variable)
                    }

                    await Promise.allSettled(promises);

                    options.progress.toggle(false);

                    if (window.abortEdits) {
                        const abortMessage = createAbortMessage();
                        const revertEditsLink = $('<a id="massxfdrevertlink">Revert?</a>');
                        revertEditsLink.on('click', revertEdits);
                        abortMessage.setLabel($('<span>').append('Edits manually aborted. ').append(revertEditsLink));
                        bodyContent.append(abortMessage.$element);
                    } else {
                        const completedElement = createCompletedElement();
                        completedElement.setLabel(options.doneMessage);
                        completedElement.$element.css('margin-bottom', '16px');
                        bodyContent.append(completedElement.$element);
                    }
                }



                const date = new Date();

                const year = date.getUTCFullYear();
                const month = date.toLocaleString('en', { month: 'long', timeZone: 'UTC' });
                const day = date.getUTCDate();

                var summaryDiscussionLink;
                var discussionPage = `${config.baseDiscussionPage}${year} ${month} ${day}`;

                if (isOld) summaryDiscussionLink = discussionLinkInputField.getValue().trim();
                else if (isNew) summaryDiscussionLink = `${discussionPage}#${newNomHeaderInputField.getValue().trim()}`;

                const advSummary = ' ([[User:Qwerfjkl/scripts/massXFD.js|via MassXfD.js]])';
                // WIP, not finished
                const categorySummary = 'Tagging page for [[' + summaryDiscussionLink + ']]' + advSummary;
                const userSummary = 'Notifying user about [[' + summaryDiscussionLink + ']]' + advSummary;
                const userNotification = `{{ subst: ${config.userNotificationTemplate} | ${summaryDiscussionLink} }} ~~~~`;
                const nominationSummary = `Adding mass nomination at [[#${newNomHeaderInputField.getValue().trim()}]]` + advSummary;
                if (XFD === 'RFD') {
                    var redirectTargetNotification = `{{subst:Rfd notice|\${redirectTitle}|${newNomHeaderInputField.getValue().trim()}}} ~~~~`
                    var redirectTargetNotificationSummary = `Notice of [[${summaryDiscussionLink}]]${advSummary}`
                }
                var batchesToProcess = [];

                var newNomPromise = new Promise(function (resolve) {
                    if (isNew) {
                        nominationText = `==== ${newNomHeaderInputField.getValue().trim()} ====\n`;
                        for (const batch of batches) {
                            var action = batch.actionInputField.getValue().trim() || false;
                            for (const page of Object.keys(batch.titles)) {
                                if (XFD == 'CFD') {
                                    var targets = batch.titles[page].slice(); // copy array
                                    var targetText = '';
                                    if (targets.length) {
                                        if (targets.length === 2) {
                                            targetText = ` to [[:${targets[0]}]] and [[:${targets[1]}]]`;
                                        }
                                        else if (targets.length > 2) {
                                            var lastTarget = targets.pop();
                                            targetText = ' to [[:' + targets.join(']], [[:') + ']], and [[:' + lastTarget + ']]';
                                        } else { // 1 target
                                            targetText = ' to [[:' + targets[0] + ']]';
                                        }
                                    }
                                    nominationText += `:* '''Propose ${action}''' {{${categoryTemplateDropdown.getValue()}|${categoryTemplateDropdown.getValue() === 'cl' ? page.replace(/^ *Category:/i, '') : page }}}${targetText}\n`;
                                } else {
                                    nominationText += config.displayTemplate.replaceAll('${pageName}', page).replaceAll('${redirectTarget}', redirectTargets[page]) + '\n';
                                }
                            }
                        }
                        var rationale = rationaleInputField.getValue().trim().replace(/\n/, '<br />');
                        nominationText += `${XFD === 'CFD' ? ":'''Nominator's rationale:''' " : ''}${rationale} ~~~~`;
                        var newText;

                        getWikitext(discussionPage).then(function (wikitext) {
                            if (!wikitext.match(config.nominationReplacement[0])) {
                                var nominationErrorMessage = createNominationErrorMessage();
                                bodyContent.append(nominationErrorMessage.$element);
                            } else {
                                newText = wikitext.replace(...config.nominationReplacement).replace('${nominationText}', nominationText);
                                batchesToProcess.push({
                                    titles: [discussionPage],
                                    textToModify: newText,
                                    summary: nominationSummary,
                                    type: 'text',
                                    doneMessage: 'Nomination added',
                                    headingLabel: 'Creating nomination'
                                });
                                resolve();
                            }
                        }).catch(function (error) {
                            console.error('An error occurred in fetching wikitext:', error);
                            resolve();
                        });
                    } else resolve();
                });
                newNomPromise.then(async function () {
                    batches.forEach(batch => {
                        batchesToProcess.push({
                            titles: batch.titles,
                            textToModify: batch.prependText,
                            summary: categorySummary,
                            type: 'prepend',
                            doneMessage: 'All pages edited.',
                            headingLabel: 'Editing nominated pages' + ((batches.length > 1) ? ' — ' + batch.label : '')
                        });
                    });
                    if (XFD === 'RFD') {
                        batchesToProcess.push({
                            id: 'rfd-notify-target',
                            titles: [... new Set(Object.values(redirectTargets).map(title => {
                                let page = new mw.Title(title)
                                return page.getTalkPage().getPrefixedText()
                            }))], // normalise redirectTargets & remove duplicates
                            textToModify: redirectTargetNotification,
                            summary: redirectTargetNotificationSummary,
                            type: 'append',
                            doneMessage: 'All target talk pages notified.',
                            headingLabel: 'Notifying targets'
                        });
                    }
                    if (notifyCheckbox.isSelected()) {
                        batchesToProcess.push({
                            titles: authors,
                            textToModify: userNotification,
                            summary: userSummary,
                            type: 'append',
                            doneMessage: 'All users notified.',
                            headingLabel: 'Notifying users'
                        });
                    }
                    let promise = Promise.resolve();
                    // abort handling is now only in the editPage() function
                    for (const batch of batchesToProcess) {
                        // alert(`starting batch ${batch.headingLabel}`)
                        await processContent(batch);
                        // alert(`batch ${batch.headingLabel} done`)
                    }

                    promise.then(() => {
                        abortButton.setLabel('Revert');
                        // All done
                    }).catch(err => {
                        console.error('Error occurred:', err);
                    });
                });

            });
        });
    }
}

// Run the script when the page is ready
$(document).ready(runMassXFD);
// </nowiki>