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": "{{subst:Cfd|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': '{{subst:Cfd|${sectionName}}}',

'action': 'deleting'

},

"Rename": {

'prepend': '{{subst:Cfr|$1|${sectionName}}}',

'action': 'renaming'

},

"Merge": {

'prepend': '{{subst:Cfm|$1|${sectionName}}}',

'action': 'merging'

},

"Split": {

'prepend': '{{subst:Cfs|$1|$2|${sectionName}}}',

'action': 'splitting'

},

"Listify": {

'prepend': '{{subst:Cfl|$1|${sectionName}}}',

'action': 'listifying'

},

"Custom": {

'prepend': '{{subst:Cfd|type=|${sectionName}}}',

'action': ''

},

},

"displayTemplates": [{

data: 'lc',

label: 'Category link with extra links – {{lc}}'

},

{

data: 'clc',

label: 'Category link with count – {{clc}}'

},

{

data: 'cl',

label: 'Plain category link – {{cl}}'

}],

},

"RFD": {

"title": "Mass RfD",

"placeholderDiscussionLink": 'Wikipedia:Redirects for discussion/Log/2024 May 13#Knightfall (comics)',

"placeholderNominationTitle": 'Knightfall',

"placeholderRationale": 'No mention of "Knightfall" in the target article.',

"pageDemoText": "",

"discussionLinkRegex": /^Wikipedia:Redirects for discussion\/Log\/\d\d\d\d \w+ \d\d?#(.+)$/,

"nominationReplacement": [//, '$&\n${nominationText}\n'],

"userNotificationTemplate": 'Rfd mass notice',

"baseDiscussionPage": 'Wikipedia:Redirects for discussion/Log/',

"normaliseFunction": (title) => { return new mw.Title(title).getPrefixedText() },

"actions":

{

'prepend': '{{subst:rfd|${sectionName}|content=\n${pageText}\n}}'

},

"displayTemplate": "{{subst:rfd2|multi=yes|redirect=${pageName}|target=${redirectTarget}}}"

}

}

const match = /Special:Mass(\w+)/.exec(mw.config.get('wgPageName'))

const XFD = match ? match[1].toUpperCase() : false

const config = XFDconfig[XFD]

function wipePageContent() {

var bodyContent = $('#bodyContent');

if (bodyContent) {

bodyContent.empty();

}

var header = $('#firstHeading');

if (header) {

header.text(config.title);

}

$('title').text(`${config.title} - Wikipedia`);

}

function createProgressElement() {

var progressContainer = new OO.ui.PanelLayout({

padded: true,

expanded: false,

classes: ['sticky-container']

});

return progressContainer;

}

function makeInfoPopup(info) {

var infoPopup = new OO.ui.PopupButtonWidget({

icon: 'info',

framed: false,

label: 'More information',

invisibleLabel: true,

popup: {

head: true,

icon: 'infoFilled',

label: 'More information',

$content: $(`

${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 delinkWikitext(text) {

// both piped and unpiped wikilinks

const wikilinkPattern = /\[\[([^\]]+)\|([^\]]+)\]\]|\[\[([^\]]+)\]\]/g;

return text.replace(wikilinkPattern, (match, p1, p2, p3) => {

if (p1 && p2) {

// If there is both link text and target (piped)

return p2; // Just return the link text

} else if (p3) {

// If there is only link text, without a target (unpiped)

return p3;

}

return match;

});

}

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) {

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 => {

if (window.debuggingMode) console.log(user);

if (user

&& (!user.blockexpiry || user.blockexpiry !== "infinite" || 'blockpartial' in user)

&& !user.groups?.includes('bot')

&& !filteredAuthorList.includes('User talk:' + user.name))

filteredAuthorList.push('User talk:' + user.name);

});

})

.catch(error => {

console.error("Error querying API:", error);

});

}

return filteredAuthorList;

} catch (error_1) {

console.error('Error occurred while creating author list:', error_1);

return authorList;

}

}

// Function to create a list of page authors and filter duplicates

async function createRedirectTargetsList(titles) {

try {

let queryBatchSize = 50;

let redirectTitles = titles.map(title => title.replace(/ /g, '_')); // Replace spaces with underscores

let redirectTargets = {};

let nonredirects = [];

for (let i = 0; i < redirectTitles.length; i += queryBatchSize) {

let batch = redirectTitles.slice(i, i + queryBatchSize);

let batchTitles = batch.join('|');

await getRedirectData(batchTitles)

.then(data => {

if ('redirects' in data) {

data.redirects.forEach(redirect => {

redirectTargets[redirect.from] = redirect.tofragment ? redirect.to+"#"+redirect.tofragment : 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 && config) {

// Load the required modules

mw.loader.using('oojs-ui').done(function () {

wipePageContent();

if (!window.debuggingMode) { // annoying when reloading for debugging

onbeforeunload = function () {

return "Closing this tab will cause you to lose all progress.";

};

}

elementsToDisable = [];

var bodyContent = $('#bodyContent');

mw.util.addCSS(`.sticky-container {

bottom: 0;

width: 100%;

max-height: 600px;

overflow-y: auto;

}`); // should probably be styled directly on the element than via the stylesheet

var nominationToggleObj = createNominationToggle();

var nominationToggle = nominationToggleObj.toggle;

bodyContent.append(nominationToggle.$element);

elementsToDisable.push(nominationToggle);

var rationaleObj = createTitleAndInputField('Rationale:', config.placeholderRationale);

var rationaleContainer = rationaleObj.container;

var rationaleInputField = rationaleObj.inputField;

elementsToDisable.push(rationaleInputField);

var nominationToggleOld = nominationToggleObj.oldNomToggle;

var nominationToggleNew = nominationToggleObj.newNomToggle;

var discussionLinkObj = createTitleAndSingleInputField('Discussion link', config.placeholderDiscussionLink);

var discussionLinkContainer = discussionLinkObj.container;

var discussionLinkInputField = discussionLinkObj.inputField;

elementsToDisable.push(discussionLinkInputField);

var newNomHeaderObj = createTitleAndSingleInputField('Nomination title', config.placeholderNominationTitle);

var newNomHeaderContainer = newNomHeaderObj.container;

var newNomHeaderInputField = newNomHeaderObj.inputField;

elementsToDisable.push(newNomHeaderInputField);

bodyContent.append(discussionLinkContainer.$element);

bodyContent.append(newNomHeaderContainer.$element, rationaleContainer.$element);

function displayElements() {

if (nominationToggleOld.isSelected()) {

discussionLinkContainer.$element.show();

newNomHeaderContainer.$element.hide();

rationaleContainer.$element.hide();

}

else if (nominationToggleNew.isSelected()) {

discussionLinkContainer.$element.hide();

newNomHeaderContainer.$element.show();

rationaleContainer.$element.show();

}

}

displayElements();

nominationToggle.on('select', displayElements);

function createActionNomination(actionsContainer, first = false) {

var count = actions.length + 1;

let actionNominationTitle = XFD === 'CFD' ? 'Action batch #' + count : '';

var container = createFieldset(actionNominationTitle);

actionsContainer.append(container.$element);

var actionDropdownObj = createActionDropdown();

var dropdown = actionDropdownObj.dropdown;

elementsToDisable.push(dropdown);

dropdown.$element.css('max-width', 'fit-content');

let demoText = config.pageDemoText;

var prependTextObj = createTitleAndInputField('Text to tag the nominated pages with:', demoText, info = 'A dollar sign $ followed by a number, such as $1, 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}', delinkWikitext(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|${delinkWikitext(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, delinkWikitext(oldSectionName), delinkWikitext(sectionName));

} else {

text = replaceOccurence(text, delinkWikitext(oldSectionName), delinkWikitext(sectionName) + '|days=' + getDateDifference(date));

}

} else text = replaceOccurence(text, delinkWikitext(oldSectionName), delinkWikitext(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, delinkWikitext(oldSectionName), delinkWikitext(sectionName));

prependTextInputField.setValue(text);

}

});

dropdown.on('labelChange', function () {

let actionData = config.actions[dropdown.getLabel()];

prependTextInputField.setValue(actionData.prepend.replace('${sectionName}', delinkWikitext(sectionName)));

actionInputField.setValue(actionData.action);

});

var titleListObj = createTitleAndInputField(`List of titles (one per line${XFD === 'CFD' ? ', Category: prefix is optional' : ''})`, 'Title1\nTitle2\nTitle3', info = 'You can specify targets by adding a pipe | and then the target, e.g. Example|Target1|Target2. 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) {

try {

if (title) {

var targets = title.split('|');

var newTitle = targets.shift();

newTitle = normalise(newTitle);

if (!Object.keys(titles).includes(newTitle)) {

titles[newTitle] = targets.map(normalise);

}

}

} catch {

console.error(`[MassXFD] Error parsing title "${title}"`);

titleListInputField.setValidityFlag(false);

error = true;

}

});

if (!(Object.keys(titles).length)) {

titleListInputField.setValidityFlag(false);

error = true;

} else {

titleListInputField.setValidityFlag(true);

}

return {

titles,

prependText: prependTextInputField.getValue().trim(),

label,

actionInputField

};

});

if (error) {

return;

}

for (let element of elementsToDisable) {

element.setDisabled(true);

}

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

var abortButton = createAbortButton();

bodyContent.append(abortButton.$element);

window.abortEdits = false; // initialise

abortButton.on('click', function () {

// Set abortEdits flag to true

if (confirm('Are you sure you want to abort?')) {

abortButton.setDisabled(true);

window.abortEdits = true;

}

});

var allTitles = batches.reduce((allTitles, obj) => {

return allTitles.concat(Object.keys(obj.titles));

}, []);

if (XFD === 'RFD') {

let fetchingRedirectsElement = createDoingElement();

fetchingRedirectsElement.setLabel('Fetching redirect targets...');

fetchingRedirectsElement.$element.css('margin-top', '16px');

bodyContent.append(fetchingRedirectsElement.$element);

let fetchedRedirectsElement = createCompletedElement();

fetchedRedirectsElement.setLabel('Fetched redirect targets');

fetchedRedirectsElement.$element.css('margin-top', '16px');

var [redirectTargets, nonredirects] = await createRedirectTargetsList(allTitles);

if (window.debuggingMode) console.log(`Redirect targets: ${JSON.stringify(redirectTargets)}`);

// console.log(Object.values(redirectTargets).map(title => {

// let page = new mw.Title(title)

// return page.getTalkPage().getPrefixedText()

// }))

// console.log([... new Set(Object.values(redirectTargets).map(title => {

// let page = new mw.Title(title)

// return page.getTalkPage().getPrefixedText()

// }))])

// window.batches=batches

batches[0].titles = Object.keys(batches[0].titles)

.filter(x => !nonredirects.includes(x))

.reduce((acc, curr) => {

acc[curr] = [];

return acc;

}, {});

if (!Object.keys(redirectTargets).length) {

var errorMessageElement = createErrorMessage('None of the titles are redirects, aborting.');

bodyContent.append(errorMessageElement.$element);

return;

}

if (nonredirects.length) {

let nonredirectsWarningMessage = createWarningMessage();

nonredirectsWarningMessage.$element.css({ 'max-height': '20em', 'overflow-y': 'auto' }); // normally shouldn't be needed

let nonRedirectsHTML = $('

').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; // dictionary is confusingly used for different things for prepend batches and for redirect notify batches

    options.titles = Object.keys(options.titles);

    } else {

    options.titlesDict = {};

    }

    const fieldset = createFieldset(options.headingLabel);

    bodyContent.append(fieldset.$element);

    options.progressElement = createProgressElement();

    fieldset.addItems([options.progressElement]);

    options.ratelimitMessage = createWarningMessage();

    options.ratelimitMessage.toggle(false);

    fieldset.addItems([options.ratelimitMessage]);

    const progressObj = createProgressBar(`(0 / ${options.titles.length}, 0 errors)`);

    options.progress = progressObj.progressBar;

    const progressContainer = progressObj.fieldlayout;

    options.progress.$element.css('margin-top', '5px');

    options.progress.pushPending();

    fieldset.addItems([progressContainer]);

    let resolvedCount = 0;

    let rejectedCount = 0;

    function updateCounter() {

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

    }

    function updateProgress() {

    const percentage = (resolvedCount + rejectedCount) / options.titles.length * 100;

    options.progress.setProgress(percentage);

    }

    function trackPromise(promise) {

    return new Promise((resolve) => {

    promise

    .then(value => {

    resolvedCount++;

    updateCounter();

    updateProgress();

    resolve(value);

    })

    .catch(error => {

    rejectedCount++;

    updateCounter();

    updateProgress();

    resolve(error);

    });

    });

    }

    const promises = [];

    for (const title of options.titles) {

    let data = deepCopy(options);

    if (XFD === 'RFD' && data.type === 'prepend') {

    const text = await getWikitext(title);

    data.textToModify = data.textToModify.replace('${pageText}', text);

    data.type = 'text';

    }

    if (data.id === 'rfd-notify-target') {

    // ${redirectTitle} is a placeholder for the redirect being nominated

    // this code needs a more intelligent way of checking which redirect was tagged.

    data.textToModify = data.textToModify.replace('${redirectTitle}', data.titlesDict[title]);

    }

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

    summaryDiscussionLink = delinkWikitext(summaryDiscussionLink); // links can't be nested

    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 #${newNomHeaderInputField.getValue().trim()}` + advSummary;

    if (XFD === 'RFD') {

    var redirectTargetNotification = `{{subst:Rfd notice|\${redirectTitle}|${newNomHeaderInputField.getValue().trim()}}} ~~~~`;

    var redirectTargetNotificationSummary = `Notice of ${summaryDiscussionLink}${advSummary}`;

    }

    var batchesToProcess = [];

    var newNomPromise = new Promise(function (resolve) {

    if (isNew) {

    nominationText = `==== ${newNomHeaderInputField.getValue().trim()} ====\n`;

    for (const batch of batches) {

    var action = batch.actionInputField.getValue().trim() || false;

    for (const page of Object.keys(batch.titles)) {

    if (XFD == 'CFD') {

    var targets = batch.titles[page].slice(); // copy array

    var targetText = '';

    if (targets.length) {

    if (targets.length === 2) {

    targetText = ` to :${targets[0]} and :${targets[1]}`;

    }

    else if (targets.length > 2) {

    var lastTarget = targets.pop();

    targetText = ' to :' + targets.join(', :') + ', and :' + lastTarget + '';

    } else { // 1 target

    targetText = ' to :' + targets[0] + '';

    }

    }

    nominationText += `:* Propose ${action} {{${categoryTemplateDropdown.getValue()}|${categoryTemplateDropdown.getValue() === 'cl' ? page.replace(/^ *Category:/i, '') : page }}}${targetText}\n`;

    } else {

    nominationText += config.displayTemplate.replaceAll('${pageName}', page).replaceAll('${redirectTarget}', redirectTargets[page]) + '\n';

    }

    }

    }

    var rationale = rationaleInputField.getValue().trim().replace(/\n/, '
    ');

    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.fromEntries(Object.keys(redirectTargets).map(title => {

    let page = new mw.Title(redirectTargets[title]);

    return [page.getTalkPage().getPrefixedText(), title];

    })), // return a map of page we want to edit : orginal title - this will (as a dicitionary) remove duplicates, but in an unpredictable way - it seems to retain the last one

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

    //