Jump to content

User:Qwerfjkl/scripts/massCFDS.js

From Wikipedia, the free encyclopedia
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>