Jump to content

User:Qwerfjkl/scripts/massCFDS.js

From Wikipedia, the free encyclopedia
The printable version is no longer supported and may have rendering errors. Please update your browser bookmarks and please use the default browser print function instead.
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
function wipePageContent() {
    var bodyContent = $('#bodyContent');
    if (bodyContent) {
        bodyContent.empty();
    }
    var header = $('#firstHeading');
    if (header) {
        header.text('Mass CfDS');
    }
    $('title').text('Mass CfDS - 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 createTitleAndInputFieldWithLabel(label, placeholder, classes = []) {
    var input = new OO.ui.TextInputWidget({
        placeholder: placeholder
    });


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

    fieldset.addItems([
        new OO.ui.FieldLayout(input, {
            label: 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: 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: titleLabel,
        inputField: inputField,
        container: container,
        infoPopup: 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: placeholder,
        indicator: 'required'
    });

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

    return {
        titleLabel: titleLabel,
        inputField: inputField,
        container: 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 createMessageElement() {
    var messageElement = new OO.ui.MessageWidget({
        type: 'progress',
        inline: true,
        progressType: 'infinite'
    });
    return messageElement;
}

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

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

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

function createNominationErrorMessage() { // pretty much a duplicate of ratelimitMessage
    var nominationErrorMessage = new OO.ui.MessageWidget({
        type: 'error',
        text: 'Could not detect where to add new nomination.'
    });
    return nominationErrorMessage;
}

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


function createMenuOptionWidget(data, label) {
    var menuOptionWidget = new OO.ui.MenuOptionWidget({
        data: data,
        label: label
    });
    return menuOptionWidget;
}
function createActionDropdown() {
    var items = [
        ['C2A-rename', 'C2A (rename)'],
        ['C2B-rename', 'C2B (rename)'],
        ['C2C-rename', 'C2C (rename)'],
        ['C2D-rename', 'C2D (rename)'],
        ['C2E-rename', 'C2E (rename)'],
        ['C2F-rename', 'C2F (rename)'],
        ['C2A-merge', 'C2A (merge)'],
        ['C2B-merge', 'C2B (merge)'],
        ['C2C-merge', 'C2C (merge)'],
        ['C2D-merge', 'C2D (merge)'],
        ['C2E-merge', 'C2E (merge)'],
        ['C2F-merge', 'C2F (merge)'],
    ].map(action => createMenuOptionWidget(...action));



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



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

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

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
function revertEdits() {
    var revertAllCount = 0;
    var revertElements = $('.masscfdsundo');
    if (!revertElements.length) {
        $('#masscfdsrevertlink').replaceWith('Reverts done.');
    } else {
        $('#masscfdsrevertlink').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: 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 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
function createAuthorList(titles) {
    var authorList = [];
    var promises = titles.map(function (title) {
        return getPageAuthor(title);
    });
    return Promise.all(promises).then(async function (authors) {
        let queryBatchSize = 50;
        let authorTitles = authors.map(author => author.replace(/ /g, '_')); // Replace spaces with underscores
        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 (user
                            && (!user.blockexpiry || user.blockexpiry !== "infinite")
                            && !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(function (error) {
        console.error('Error occurred while creating author list:', error);
        return authorList;
    });
}

// Function to prepend text to a page
function editPage(title, text, summary, progressElement, ratelimitMessage, progress, type, titlesDict, retry = false) {
    var api = new mw.Api();

    var messageElement = createMessageElement();



    messageElement.setLabel((retry) ? $('<span>').text('Retrying ').append($(makeLink(title))) : $('<span>').text('Editing ').append($(makeLink(title))));
    progressElement.$element.append(messageElement.$element);
    var container = $('.sticky-container');
    container.scrollTop(container.prop("scrollHeight"));
    if (retry) {
        sleep(1000);
    }

    var requestData = {
        action: 'edit',
        title: title,
        summary: summary,
        format: 'json'
    };

    if (type === 'prepend') { // cat
        requestData.nocreate = 1; // don't create new cat
        // parse title
        var targets = titlesDict[title];

        for (let i = 0; i < targets.length; i++) {
            // we add 1 to i in the replace function because placeholders start from $1 not $0
            let placeholder = '$' + (i + 1);
            text = text.replace(placeholder, targets[i]);
        }
        text = text.replace(/\$\d/g, ''); // remove unmatched |$x
        requestData.prependtext = text.trim() + '\n\n';


    } else if (type === 'append') { // user
        requestData.appendtext = '\n\n' + text.trim();
    } else if (type === 'text') {
        requestData.text = text;
    }
    return new Promise(function (resolve, reject) {
        if (window.abortEdits) {
            // hide message and return
            messageElement.toggle(false);
            resolve();
            return;
        }
        api.postWithEditToken(requestData).then(function (data) {
            if (data.edit && data.edit.result === 'Success') {
                messageElement.setType('success');
                messageElement.setLabel($('<span>' + makeLink(title) + ' edited successfully</span><span class="masscfdsundo" data-revid="' + data.edit.newrevid + '" data-title="' + title + '"></span>'));

                resolve();
            } else {

                messageElement.setType('error');
                messageElement.setLabel($('<span>Error occurred while editing ' + makeLink(title) + ': ' + data + '</span>'));
                console.error('Error occurred while prepending text to page:', data);

                reject();
            }
        }).catch(function (error) {
            messageElement.setType('error');
            messageElement.setLabel($('<span>Error occurred while editing ' + makeLink(title) + ': ' + error + '</span>'));
            console.error('Error occurred while prepending text to page:', error); // handle: editconflict, ratelimit (retry)
            if (error == 'editconflict') {
                editPage(title, text, summary, progressElement, ratelimitMessage, progress, type, titlesDict, retry = true).then(function () {
                    resolve();
                });
            } else if (error == 'ratelimited') {
                progress.setDisabled(true);

                handleRateLimitError(ratelimitMessage).then(function () {
                    progress.setDisabled(false);
                    editPage(title, text, summary, progressElement, ratelimitMessage, progress, type, titlesDict, retry = true).then(function () {
                        resolve();
                    });
                });
            }
            else {
                reject();
            }
        });
    });
}

// global scope - needed to syncronise ratelimits
var massCFDSratelimitPromise = 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 (massCFDSratelimitPromise !== null) {
        return massCFDSratelimitPromise;
    }

    massCFDSratelimitPromise = 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);
                massCFDSratelimitPromise = 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);
            massCFDSratelimitPromise = null; // reset
            resolve();
        }, secondsToWait);
    });
    return massCFDSratelimitPromise;
}

// 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: label,
        align: 'inline'
    });
    return {
        progressBar: progressBar,
        fieldlayout: fieldlayout
    };
}


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

    mw.util.addPortletLink('p-tb', mw.util.getUrl('Special:MassCFDS'), 'Mass CfDS', 'pt-masscfds', 'Create a mass CfDS nomination');

    if (/Special:MassCFDS/i.test(mw.config.get('wgPageName'))) {
        // Load the required modules
        mw.loader.using('oojs-ui').done(function () {
            wipePageContent();
            //   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;
          }`);


            var rationaleObj = createTitleAndSingleInputField('Rationale:', 'Per [[Talk:Libyan civil war (2011)/Archive 13#Requested move 17 July 2023|past move discussion]] that resulted in [[First Libyan Civil War]] being moved to [[Libyan civil war (2011)]].'); // from [[:Special:Diff/1223231909#mw-diff-ntitle1]]
            var rationaleContainer = rationaleObj.container;
            var rationaleInputField = rationaleObj.inputField;
            elementsToDisable.push(rationaleInputField);

            bodyContent.append(rationaleContainer.$element);





            var dropdown = createActionDropdown();
            elementsToDisable.push(dropdown);
            dropdown.$element.css('max-width', 'fit-content');
            bodyContent.append(dropdown.$element);

            var prependTextObj = createTitleAndInputField('Wikitext to tag category page with:', '{{subst:cfr-speedy|Category:Bishops}}', 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
            });

            prependTextContainer.$element.append(prependTextLabel.$element, prependTextInfoPopup.$element, dropdown.$element, prependTextInputField.$element);
            bodyContent.append(prependTextContainer.$element);


            var nominationType = false;
            var C2X = false;
            dropdown.on('labelChange', function () {
                switch (dropdown.getMenu().findSelectedItem().getData().split("-").pop()) {
                    case "rename":
                        prependTextInputField.setValue(`{{subst:cfr-speedy|$1}}`);
                        nominationType = 'renaming';
                        break;
                    case "merge":
                        prependTextInputField.setValue(`{{subst:cfm-speedy|$1}}`);
                        nominationType = 'merging';
                        break;
                }
                C2X = dropdown.getMenu().findSelectedItem().getData().split("-").shift();

            });




            var titleListObj = createTitleAndInputField('List of titles (one per line, <code>Category:</code> prefix is optional)', 'Title1|Target1\nTitle2|Target2a|Target2b\nTitle3|Target3', info = 'You can specify targets by adding a pipe <code>|</code> and then the target, e.g. <code>Category:Example|Category:Target1|Category:Target2</code>. These targets can be used in the category tagging step.');
            var titleList = titleListObj.container;
            var titleListInputField = titleListObj.inputField;
            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);
            bodyContent.append(titleList.$element);

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



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

                // First check elements
                var error = false;

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

                if (!titleListInputField.getValue().trim() || !titleListInputField.getValue().includes('|') ) { // for CfDS there should always be a target
                    titleListInputField.setValidityFlag(false);
                    error = true;
                } else {
                    titleListInputField.setValidityFlag(true);
                }

                if (!nominationType) { // needed to select C2X
                    // dropdown.setValidityFlag(false);
                    error = true;
                } else {
                    // dropdown.setValidityFlag(true);
                }

                // Retreive titles, handle dups
                var titles = {};
                var titleList = titleListInputField.getValue().split('\n');
                function capitalise(s) {
                    return s[0].toUpperCase() + s.slice(1);
                }
                function normalise(title) {
                    return 'Category:' + capitalise(title.replace(/^ *[Cc]ategory:/, '').trim());
                }
                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);
                }


                if (error) {
                    return;
                }

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



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


                function processContent(content, titles, textToModify, summary, type, doneMessage, headingLabel) {
                    if (!Array.isArray(titles)) {
                        var titlesDict = titles;
                        titles = Object.keys(titles);
                    }
                    var fieldset = createFieldset(headingLabel);

                    content.append(fieldset.$element);

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

                    var ratelimitMessage = createRatelimitMessage();
                    ratelimitMessage.toggle(false);
                    fieldset.addItems([ratelimitMessage]);

                    var progressObj = createProgressBar(`(0 / ${titles.length}, 0 errors)`); // with label
                    var progress = progressObj.progressBar;
                    var progressContainer = progressObj.fieldlayout;
                    // Add margin or padding to the progress bar widget
                    progress.$element.css('margin-top', '5px');
                    progress.pushPending();
                    fieldset.addItems([progressContainer]);

                    let resolvedCount = 0;
                    let rejectedCount = 0;

                    function updateCounter() {
                        progressContainer.setLabel(`(${resolvedCount} / ${titles.length}, ${rejectedCount} errors)`);
                    }
                    function updateProgress() {
                        var percentage = (resolvedCount + rejectedCount) / titles.length * 100;
                        progress.setProgress(percentage);

                    }

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

                    return new Promise(async function (resolve) {
                        var promises = [];
                        for (const title of titles) {
                            var promise = editPage(title, textToModify, summary, progressElement, ratelimitMessage, progress, type, titlesDict);
                            promises.push(trackPromise(promise));
                            await sleep(100); // space out calls
                            await massCFDSratelimitPromise; // stop if ratelimit reached (global variable)
                        }

                        Promise.allSettled(promises)
                            .then(function () {
                                progress.toggle(false);
                                if (window.abortEdits) {
                                    var abortMessage = createAbortMessage();
                                    abortMessage.setLabel($('<span>Edits manually aborted. <a id="masscfdsrevertlink" onclick="revertEdits()">Revert?</a></span>'));

                                    content.append(abortMessage.$element);
                                } else {
                                    var completedElement = createCompletedElement();
                                    completedElement.setLabel(doneMessage);
                                    completedElement.$element.css('margin-bottom', '16px');
                                    content.append(completedElement.$element);
                                }
                                resolve();
                            })
                            .catch(function (error) {
                                console.error("Error occurred during title processing:", error);
                                resolve();
                            });
                    });
                }



                var discussionPage = 'Wikipedia:Categories for discussion/Speedy';

                const advSummary = ' ([[User:Qwerfjkl/scripts/massCFDS|via MassCfDS.js]])';
                const categorySummary = `Tagging category for [[Wikipedia:Categories for discussion/Speedy|speedy ${nominationType ? nominationType : 'nomination'}]]` + advSummary;
                const nominationSummary = 'Adding mass speedy nomination' + advSummary;


                var batchesToProcess = [];
                const titlesForTagging = structuredClone(titles);
                var newNomPromise = new Promise(function (resolve) {
                let nominationText = '';
                    function makeCategoryNominationText(category, targets, first = false) {
                        let targetText = '';
                        if (targets.length) {
                            if (targets.length === 2) {
                                targetText = `to [[:${targets[0]}]] and [[:${targets[1]}]]`;
                            }
                            else if (targets.length > 2) {
                                let lastTarget = targets.pop();
                                targetText = 'to [[:' + targets.join(']], [[:') + ']], and [[:' + lastTarget + ']]';
                            } else { // 1 target
                                targetText = 'to [[:' + targets[0] + ']]';
                            }
                        }
                        return `${first ? '' : '*'}* [[:${category}]] ${targetText}${first ? ' – ' + C2X + ': ' + rationaleInputField.getValue().trim() + ' ~~~~' : ''}\n`;
                    }
                    let firstCategory = Object.keys(titles)[0];
                    let firstTargets = titles[firstCategory];
                    delete titles[firstCategory];

                    nominationText += makeCategoryNominationText(firstCategory, firstTargets, first = true);
                    for (const category in titles) {
                        var targets = titles[category].slice(); // copy array
                        nominationText += makeCategoryNominationText(category, targets);
                    }

                    var newText;
                    var nominationRegex = /<!-- *PLACE NEW NOMINATIONS AT THE TOP OF THIS LIST, BELOW THIS LINE *-->/;
                    getWikitext(discussionPage).then(function (wikitext) {
                        if (!wikitext.match(nominationRegex)) {
                            var nominationErrorMessage = createNominationErrorMessage();
                            bodyContent.append(nominationErrorMessage.$element);
                        } else {
                            newText = wikitext.replace(nominationRegex, '$&\n' + nominationText.trimEnd()); // $& contains all the matched text
                            batchesToProcess.push({
                                content: bodyContent,
                                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();
                    });
                });
                await newNomPromise;
                batchesToProcess.push({
                    content: bodyContent,
                    titles: titlesForTagging,
                    textToModify: prependTextInputField.getValue().trim(),
                    summary: categorySummary,
                    type: 'prepend',
                    doneMessage: 'All categories edited.',
                    headingLabel: 'Tagging categories'
                });
                
                let promise = Promise.resolve();
                // abort handling is now only in the editPage() function
                for (const batch of batchesToProcess) {
                    await processContent(...Object.values(batch));
                }

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

        });
    }
}


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