User:Qwerfjkl/scripts/massXFD.js

//

// 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": 'Non-defining category.', "pageDemoText": "Category:Bishops", "discussionLinkRegex": /^Wikipedia:Categories for discussion\/Log\/\d\d\d\d \w+ \d\d?#(.+)$/, "nominationReplacement": [/==== ?NEW NOMINATIONS ?====\s*(?:)?/, '$&\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': '${sectionName}', 'action': 'deleting' },           "Rename": { 'prepend': '$1', 'action': 'renaming' },           "Merge": { 'prepend': '$1', 'action': 'merging' },           "Split": { 'prepend': '$1', 'action': 'splitting' },           "Listify": { 'prepend': '$1', 'action': 'listifying' },           "Custom": { 'prepend': '${sectionName}', 'action': '' },       },        "displayTemplates": [{ data: 'lc', label: 'Category link with extra links – ' },       {            data: 'clc', label: 'Category link with count – ' },       {            data: 'cl', label: 'Plain category link – ' }],

},   "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": [//, '$&\n${nominationText}\n'], "userNotificationTemplate": 'Rfd mass notice', "baseDiscussionPage": 'Wikipedia:Redirects for discussion/Log/', "normaliseFunction": (title) => { return new mw.Title(title).getPrefixedText }, "actions": {           'prepend': '${sectionName}' },       "displayTemplate": "" } } 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: $(` ${info} `),            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: $(` ${title} `)    });

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 `${title}`; }

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 = $(' ').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(' Reverting... ( 0 / ' + revertElements.length + ' done) ');

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. Click here 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 => { 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)       ? $(' ').text('Retrying ').append($(makeLink(localOptions.title)))        : $(' ').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($(' ' + makeLink(localOptions.title) + ' edited successfully  '));                    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($(' ' + msg + ' ' + makeLink(options.title) + ': ' + error + ' ')); 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) { // 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  followed by a number, such as , 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' ? ',  prefix is optional' : ''})`, 'Title1\nTitle2\nTitle3', info = 'You can specify targets by adding a pipe   and then the target, e.g.  . 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 = $(' '); 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(' ');

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); // 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 = $(' ').append($(' ').text('The following pages were ignored because they are not redirects:')) let $listElement = $('') nonredirects.forEach(item => {                           const $listItem = $('').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') { data.textToModify = data.textToModify.replace('${redirectTitle}', 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 = $('Revert?'); revertEditsLink.on('click', revertEdits); abortMessage.setLabel($(' ').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 = ' (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 ` + 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/, ' '); 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: Object.values(redirectTargets).map(title => { let page = new mw.Title(title) return page.getTalkPage.getPrefixedText }),                           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); //