User:Barkeep49/rfxCloser.js
Appearance
Code that you insert on this page could contain malicious content capable of compromising your account. If you import a script from another page with "importScript", "mw.loader.load", "iusc", or "lusc", take note that this causes you to dynamically load a remote script, which could be changed by others. Editors are responsible for all edits and actions they perform, including by scripts. User scripts are not centrally supported and may malfunction or become inoperable due to software changes. A guide to help you find broken scripts is available. If you are unsure whether code you are adding to this page is safe, you can ask at the appropriate village pump. This code will be executed when previewing this page. |
![]() | Documentation for this user script can be added at User:Barkeep49/rfxCloser. |
/**
* RfX Closer User Script for Wikipedia
* Helps bureaucrats close RfAs and RfBs by providing a guided workflow and API interactions.
* Version 1.0.1
*/
(function() {
'use strict';
// --- Configuration ---
const config = {
pageName: mw.config.get('wgPageName'),
userName: mw.config.get('wgUserName') || 'YourUsername',
tagLine: ' (using [[User:Barkeep49/rfxCloser.js|rfxCloser]])',
selectors: {
container: '#rfx-closer-container',
header: '.rfx-closer-header',
title: '.rfx-closer-title',
collapseButton: '.rfx-closer-collapse',
closeButton: '.rfx-closer-close',
contentAndInputContainer: '.rfx-closer-main-content',
inputSection: '.rfx-closer-input-section',
inputFields: '.rfx-closer-input-fields',
supportInput: '#support-count',
opposeInput: '#oppose-count',
neutralInput: '#neutral-count',
percentageDisplay: '.rfx-closer-percentage',
contentContainer: '.rfx-closer-content-container',
stepsContainer: '#rfx-closer-steps',
outcomeSelector: '#rfx-outcome-selector',
launchButton: '#rfx-closer-launch',
launchListItem: '#rfx-closer-launch-li',
toolsMenu: '#p-tb ul',
cratListTextarea: '#rfx-crat-list-textarea',
cratNotifyMessage: '#rfx-crat-notify-message',
cratNotifyButton: '#rfx-crat-notify-button',
cratNotifyStatus: '#rfx-crat-notify-status',
candidateOnholdNotifyMessage: '#rfx-candidate-onhold-notify-message',
candidateOnholdNotifyButton: '#rfx-candidate-onhold-notify-button',
candidateOnholdNotifyStatus: '#rfx-candidate-onhold-notify-status',
stepElement: '.rfx-closer-step',
stepCheckbox: 'input[type="checkbox"]'
},
hardcodedCrats: [
'28bytes', 'Acalamari', 'AmandaNP', 'Avraham', 'Barkeep49',
'Bibliomaniac15', 'Cecropia', 'Dweller', 'Lee Vilenski', 'Maxim',
'Primefac', 'UninvitedCompany', 'Useight', 'WereSpielChequers',
'Xaosflux', 'Xeno'
],
groupDisplayNames: {
'ipblock-exempt': 'IP block exempt', 'rollbacker': 'Rollbacker',
'eventcoordinator': 'Event coordinator', 'filemover': 'File mover',
'templateeditor': 'Template editor', 'massmessage-sender': 'Mass message sender',
'extendedconfirmed': 'Extended confirmed user', 'extendedmover': 'Page mover',
'patroller': 'New page reviewer', 'abusefilter-helper': 'Edit filter helper',
'abusefilter': 'Edit filter manager', 'reviewer': 'Pending changes reviewer',
'accountcreator': 'Account creator', 'autoreviewer': 'Autopatrolled'
},
apiDefaults: {
format: 'json',
formatversion: 2
}
};
// Determine RfX type and base page name
config.rfxType = config.pageName.includes('Requests_for_adminship') ? 'adminship' : 'bureaucratship';
config.baseRfxPage = `Wikipedia:Requests_for_${config.rfxType}`;
config.displayBaseRfxPage = config.baseRfxPage.replace(/_/g, ' ');
config.candidateSubpage = config.pageName.split('/').pop(); // Get the candidate part
// --- State Variables ---
let rfaData = null;
let basePageWikitextCache = {};
let actualCandidateUsername = config.candidateSubpage; // Default, updated on fetch
let fetchErrorOccurred = false;
let wikitextErrorOccurred = false;
let isCollapsed = false;
let isDragging = false;
let dragStartX, dragStartY, containerStartX, containerStartY;
// --- Initial Check ---
if (!new RegExp(`^${config.baseRfxPage.replace(/ /g, '_')}/[^/]+$`).test(config.pageName)) {
return;
}
// --- Utility Functions ---
/** Escapes characters for use in regex, handling spaces/underscores. */
function escapeRegex(string) {
const spacedString = string.replace(/_/g, ' ');
const underscoredString = string.replace(/ /g, '_');
if (spacedString === underscoredString) {
return spacedString.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
const escapedSpaced = spacedString.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const escapedUnderscored = underscoredString.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
return `(?:${escapedUnderscored}|${escapedSpaced})`;
}
/** Adds a delay. */
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
/** Generic API request helper. */
async function makeApiRequest(params, method = 'get', tokenType = null) {
const api = new mw.Api();
const fullParams = { ...config.apiDefaults, ...params };
try {
let response;
if (method === 'get') {
response = await api.get(fullParams);
} else if (method === 'post' && tokenType) {
response = await api.postWithToken(tokenType, fullParams);
} else if (method === 'post') {
response = await api.post(fullParams);
} else {
throw new Error(`Unsupported API method: ${method}`);
}
return response;
} catch (error) {
const errorCode = error?.error?.code || error?.textStatus || 'unknown';
const errorInfo = error?.error?.info || error?.xhr?.responseText || 'Unknown API error';
console.error(`RfX Closer API [${method.toUpperCase()} ${params.action}] Error:`, { params, errorCode, errorInfo, errorObj: error });
throw { code: errorCode, info: errorInfo, params: params };
}
}
/** Creates a DOM element with attributes and content. */
function createElement(tag, options = {}, children = []) {
const el = document.createElement(tag);
Object.entries(options).forEach(([key, value]) => {
if (key === 'style' && typeof value === 'object') {
Object.assign(el.style, value);
} else if (key === 'dataset' && typeof value === 'object') {
Object.assign(el.dataset, value);
} else if (key === 'className') {
el.className = value;
} else if (key === 'textContent') {
el.textContent = value;
} else if (key === 'innerHTML') {
el.innerHTML = value;
} else {
el.setAttribute(key, value);
}
});
children.forEach(child => {
if (child instanceof Node) {
el.appendChild(child);
} else if (typeof child === 'string') {
el.appendChild(document.createTextNode(child));
}
});
return el;
}
/** Creates a standard API action button. */
function createActionButton(id, text, onClick) {
const button = createElement('button', { id, textContent: text, className: 'rfx-closer-action-button' });
if (onClick) {
button.addEventListener('click', onClick);
}
return button;
}
/** Creates a standard status display area. */
function createStatusArea(id, className = 'rfx-closer-api-status') {
return createElement('div', { id, className });
}
/** Creates a container with "Copy Code" and "Edit Page" links. */
function createActionLinks(targetPage, wikitextOrGetter, linkTextPrefix = '', isCreateLink = false) {
const linksContainer = createElement('div', { className: 'rfx-action-links-container' });
const copyLink = createElement('a', {
href: '#',
textContent: `Copy Code${linkTextPrefix ? ' for ' + linkTextPrefix : ''}`,
className: 'rfx-action-link',
title: 'Copy generated wikitext to clipboard'
});
copyLink.addEventListener('click', (e) => {
e.preventDefault();
const textToCopy = (typeof wikitextOrGetter === 'function') ? wikitextOrGetter() : wikitextOrGetter;
if (textToCopy === null || textToCopy === undefined) {
console.warn("RfX Closer: Attempted to copy null/undefined text via link.");
copyLink.textContent = 'Error (No Text)'; copyLink.classList.add('error');
setTimeout(() => { copyLink.textContent = `Copy Code${linkTextPrefix ? ' for ' + linkTextPrefix : ''}`; copyLink.classList.remove('error'); }, 3000);
return;
}
navigator.clipboard.writeText(textToCopy).then(() => {
copyLink.textContent = 'Copied!'; copyLink.classList.add('copied'); copyLink.style.fontWeight = 'bold';
setTimeout(() => { copyLink.textContent = `Copy Code${linkTextPrefix ? ' for ' + linkTextPrefix : ''}`; copyLink.classList.remove('copied'); copyLink.style.fontWeight = 'normal'; }, 1500);
}).catch(err => {
console.error('RfX Closer: Failed to copy text via link: ', err);
copyLink.textContent = 'Error Copying!'; copyLink.classList.add('error');
setTimeout(() => { copyLink.textContent = `Copy Code${linkTextPrefix ? ' for ' + linkTextPrefix : ''}`; copyLink.classList.remove('error'); }, 3000);
});
});
linksContainer.appendChild(copyLink);
if (targetPage) {
const editLink = createElement('a', {
href: mw.util.getUrl(targetPage, { action: 'edit' }),
target: '_blank',
textContent: `${isCreateLink ? 'Create' : 'Edit'} ${linkTextPrefix || targetPage.replace(/_/g, ' ')}`,
className: 'rfx-action-link',
title: `${isCreateLink ? 'Create' : 'Edit'} ${targetPage.replace(/_/g, ' ')} in edit mode`
});
linksContainer.appendChild(editLink);
}
return linksContainer;
}
/** Creates a box with content and a copy button (kept for fallback cases). */
function createCopyableBox(content, helpText = 'Click button to copy', isLarge = false, inputElement = null) {
const boxContainer = createElement('div', { className: 'rfx-copy-box-container' });
const displayElement = inputElement ? inputElement : createElement('pre', {
textContent: content,
className: 'rfx-copy-box-display',
style: isLarge ? { maxHeight: '300px' } : {}
});
if (!inputElement) { boxContainer.appendChild(displayElement); }
const copyButton = createElement('button', {
textContent: 'Copy',
title: helpText,
className: 'rfx-copy-box-button',
style: (inputElement && inputElement.tagName === 'TEXTAREA') ? { top: 'auto', bottom: '5px' } : {}
});
copyButton.addEventListener('click', () => {
const textToCopy = inputElement ? inputElement.value : content;
if (textToCopy === null || textToCopy === undefined) { console.warn("RfX Closer: Attempted to copy null/undefined."); return; }
navigator.clipboard.writeText(textToCopy).then(() => {
copyButton.textContent = 'Copied!'; copyButton.classList.add('copied'); copyButton.disabled = true;
setTimeout(() => { copyButton.textContent = 'Copy'; copyButton.classList.remove('copied'); copyButton.disabled = false; }, 1500);
}).catch(err => {
console.error('RfX Closer: Failed to copy text: ', err); copyButton.textContent = 'Error!'; copyButton.classList.add('error');
setTimeout(() => { copyButton.textContent = 'Copy'; copyButton.classList.remove('error'); copyButton.disabled = false; }, 3000);
});
});
boxContainer.appendChild(copyButton);
boxContainer.appendChild(createElement('div', { textContent: helpText, className: 'rfx-copy-box-helptext' }));
return boxContainer;
}
// --- Data Fetching Functions ---
/** Fetches summary data from the main RfX list page. */
async function fetchRfaData() {
if (rfaData && !fetchErrorOccurred) return rfaData;
fetchErrorOccurred = false;
try {
const data = await makeApiRequest({
action: 'parse',
page: config.baseRfxPage,
prop: 'text'
});
const htmlContent = data.parse.text;
const tempDiv = createElement('div', { innerHTML: htmlContent });
const reportTable = tempDiv.querySelector('.rfx-report');
let foundData = null;
if (reportTable) {
const rows = reportTable.querySelectorAll('tbody tr');
rows.forEach(row => {
const rfaLink = row.querySelector('td:first-child a');
const linkHref = rfaLink ? rfaLink.getAttribute('href').replace(/ /g, '_') : '';
const targetHref = `/wiki/${config.baseRfxPage.replace(/ /g, '_')}/${config.candidateSubpage}`;
if (rfaLink && linkHref === targetHref) {
const cells = row.querySelectorAll('td');
if (cells.length >= 8) {
foundData = {
candidate: rfaLink.textContent.trim(),
support: cells[1]?.textContent.trim() || '0',
oppose: cells[2]?.textContent.trim() || '0',
neutral: cells[3]?.textContent.trim() || '0',
percent: cells[4]?.textContent.trim().replace('%', '') || 'N/A',
status: cells[5]?.textContent.trim() || 'N/A',
endTime: cells[6]?.textContent.trim() || 'N/A',
timeLeft: cells[7]?.textContent.trim() || 'N/A',
};
}
}
});
}
if (foundData) {
rfaData = foundData;
actualCandidateUsername = rfaData.candidate;
} else {
throw new Error(`Could not find summary data for ${config.candidateSubpage}`);
}
return rfaData;
} catch (error) {
console.warn(`RfX Closer: Error fetching or parsing summary data: ${error.info || error.message}. Using defaults.`);
fetchErrorOccurred = true;
rfaData = { candidate: actualCandidateUsername, support: '0', oppose: '0', neutral: '0', percent: 'N/A', status: 'N/A', endTime: 'N/A', timeLeft: 'N/A' };
return rfaData;
}
}
/** Fetches the full wikitext of a given page, using cache. */
async function fetchPageWikitext(targetPageName) {
const normalizedPageName = targetPageName.replace(/ /g, '_');
if (basePageWikitextCache[normalizedPageName]) {
return basePageWikitextCache[normalizedPageName];
}
try {
const data = await makeApiRequest({
action: 'query',
prop: 'revisions',
titles: normalizedPageName,
rvslots: 'main',
rvprop: 'content'
});
const page = data.query.pages[0];
if (page && page.missing) {
basePageWikitextCache[normalizedPageName] = '';
return '';
}
if (page && page.revisions?.[0]?.slots?.main?.content) {
const wikitext = page.revisions[0].slots.main.content;
basePageWikitextCache[normalizedPageName] = wikitext;
if (normalizedPageName === config.pageName.replace(/ /g, '_')) wikitextErrorOccurred = false;
return wikitext;
} else {
throw new Error(`Could not find wikitext content for page ${normalizedPageName}. Response: ${JSON.stringify(data)}`);
}
} catch (error) {
console.error(`RfX Closer: Error fetching wikitext for ${normalizedPageName}:`, error);
if (normalizedPageName === config.pageName.replace(/ /g, '_')) wikitextErrorOccurred = true;
return null;
}
}
/** Specific fetcher for the current RfX page wikitext. */
const fetchRfXWikitext = () => {
wikitextErrorOccurred = false; // Reset flag
return fetchPageWikitext(config.pageName);
};
/** API function to check user groups. */
async function getUserGroups(username) {
try {
const data = await makeApiRequest({
action: 'query', list: 'users', ususers: username, usprop: 'groups'
});
const user = data.query?.users?.[0];
if (user && !user.missing && !user.invalid) {
const groups = user.groups || [];
console.log(`RfX Closer getUserGroups: Found groups for ${username}:`, groups);
return groups;
} else {
console.warn(`RfX Closer getUserGroups: User '${username}' not found or invalid.`);
return null; // Indicate user not found/invalid
}
} catch (error) {
console.error(`RfX Closer getUserGroups: API error checking groups for '${username}'.`, error);
return null; // Indicate API error
}
}
/** API function to grant/remove rights. */
function grantPermissionAPI(username, groupToAdd, reason, groupsToRemove = null, expiry = 'infinity') {
const params = {
action: 'userrights',
user: username,
reason: reason,
expiry: expiry
};
if (groupToAdd) params.add = groupToAdd;
if (groupsToRemove) params.remove = groupsToRemove; // Expects pipe-separated string
return makeApiRequest(params, 'post', 'userrights');
}
/** Helper function to post a message to a talk page. */
async function postToTalkPage(targetPage, sectionTitle, messageContent, summary) {
try {
await makeApiRequest({
action: 'edit',
title: targetPage,
section: 'new',
sectiontitle: sectionTitle,
text: messageContent,
summary: summary
}, 'post', 'edit');
return { success: true, page: targetPage };
} catch (error) {
return { success: false, page: targetPage, error: `${error.info} (${error.code})` };
}
}
// --- UI Update Functions ---
/** Updates the percentage display based on input fields. */
const updatePercentageDisplay = () => {
const supportInputEl = document.querySelector(config.selectors.supportInput);
const opposeInputEl = document.querySelector(config.selectors.opposeInput);
const percentageDivEl = document.querySelector(config.selectors.percentageDisplay);
if (!supportInputEl || !opposeInputEl || !percentageDivEl) return;
const support = parseInt(supportInputEl.value, 10) || 0;
const oppose = parseInt(opposeInputEl.value, 10) || 0;
const total = support + oppose;
const percentage = total > 0 ? (support / total * 100).toFixed(2) : 0;
percentageDivEl.textContent = `Support percentage: ${percentage}% (${support}/${total})`;
};
/** Updates the input fields with fetched or default data. */
const updateInputFields = async () => {
try {
const data = await fetchRfaData(); // Ensures data is fetched/available
const supportInputEl = document.querySelector(config.selectors.supportInput);
const opposeInputEl = document.querySelector(config.selectors.opposeInput);
const neutralInputEl = document.querySelector(config.selectors.neutralInput);
if (supportInputEl && opposeInputEl && neutralInputEl) {
supportInputEl.value = data?.support || '';
opposeInputEl.value = data?.oppose || '';
neutralInputEl.value = data?.neutral || '';
updatePercentageDisplay();
} else {
console.error("RfX Closer: Could not find input elements to update.");
}
} catch (error) {
// Error already logged by fetchRfaData, just ensure UI shows defaults
console.error("RfX Closer: Error updating input fields, likely due to fetch failure.");
updatePercentageDisplay(); // Update percentage based on potentially empty fields
}
};
/** Helper to get current vote counts from input fields. */
function getCurrentVoteCounts() {
const supportInputEl = document.querySelector(config.selectors.supportInput);
const opposeInputEl = document.querySelector(config.selectors.opposeInput);
const neutralInputEl = document.querySelector(config.selectors.neutralInput);
return {
support: supportInputEl?.value || '0',
oppose: opposeInputEl?.value || '0',
neutral: neutralInputEl?.value || '0'
};
}
/** Updates count line (e.g., '''X successful candidacies so far''') in wikitext. */
function updateCountInWikitext(wikitext, countRegex, delta = 1) {
let updated = false;
const newWikitext = wikitext.replace(countRegex, (match, openingFormatting, currentCountStr, closingFormatting) => {
const currentCount = parseInt(currentCountStr, 10);
if (!isNaN(currentCount)) {
const newCount = currentCount + delta;
updated = true;
const textPart = match.replace(openingFormatting || '', '').replace(currentCountStr, '').replace(closingFormatting || '', '');
return `${openingFormatting || ''}${newCount}${textPart}${closingFormatting || ''}`;
}
return match; // Return original match if count couldn't be parsed
});
return updated ? newWikitext : wikitext; // Return original if no update occurred
}
// --- Step Description Render Functions (Extracted) ---
function renderStep0Description() { /* Check Timing */
const stepContainer = createElement('div');
stepContainer.appendChild(createElement('p', {
innerHTML: `Verify that at least 7 days have passed since the listing on <a href="/wiki/${config.baseRfxPage}" target="_blank">${config.displayBaseRfxPage}</a>.`
}));
const timingInfoContainer = createElement('div', {
className: 'rfx-closer-info-box',
textContent: 'Loading timing info...'
});
stepContainer.appendChild(timingInfoContainer);
fetchRfaData().then(data => {
if (fetchErrorOccurred) {
timingInfoContainer.textContent = 'Could not load timing info. Check console for errors.';
timingInfoContainer.classList.add('error');
} else {
const isTooEarly = data.status ? data.status.toLowerCase() === 'open' : true;
timingInfoContainer.innerHTML = `End Time (UTC): ${data.endTime || 'N/A'}<br>Time Left: ${data.timeLeft || 'N/A'}<br>Status: ${data.status || 'N/A'}`;
timingInfoContainer.style.backgroundColor = isTooEarly ? '#ffe6e6' : '#e6f3e6';
timingInfoContainer.style.borderColor = isTooEarly ? '#f5c6cb' : '#c3e6cb';
timingInfoContainer.classList.remove('error');
}
}).catch(() => {
timingInfoContainer.textContent = 'Error processing timing info.';
timingInfoContainer.classList.add('error');
});
return stepContainer;
}
function renderStep1Description() { /* Verify History */
const historyUrl = `/w/index.php?title=${encodeURIComponent(config.pageName)}&action=history`;
return createElement('div', {}, [
createElement('p', { textContent: 'Check the history of the transcluded page to ensure comments are genuine and haven\'t been tampered with.' }),
createElement('a', {
href: historyUrl,
target: '_blank',
textContent: 'View page history',
style: { display: 'inline-block', marginTop: '5px', padding: '5px 10px', backgroundColor: '#f8f9fa', border: '1px solid #a2a9b1', borderRadius: '3px', textDecoration: 'none' }
})
]);
}
function renderStep2Description() { /* Determine Consensus */
const stepContainer = createElement('div');
stepContainer.appendChild(createElement('p', {
innerHTML: 'Use traditional rules of thumb and your best judgement to determine consensus. Consider:<br>- Vote counts<br>- Quality of arguments<br>- Contributor weight<br>- Concerns raised and resolution'
}));
const voteTallyContainer = createElement('div', {
className: 'rfx-closer-info-box',
textContent: 'Loading vote tally...'
});
stepContainer.appendChild(voteTallyContainer);
fetchRfaData().then(data => {
if (fetchErrorOccurred) {
voteTallyContainer.textContent = 'Could not load vote tally. Check console for errors.';
voteTallyContainer.classList.add('error');
} else {
voteTallyContainer.innerHTML = `<strong>Current Vote Tally:</strong><br>Support: ${data.support || 'N/A'}<br>Oppose: ${data.oppose || 'N/A'}<br>Neutral: ${data.neutral || 'N/A'}<br>Support %: ${data.percent !== 'N/A' ? data.percent + '%' : 'N/A'}`;
const numericPercent = parseFloat(data.percent);
if (!isNaN(numericPercent)) {
if (numericPercent >= 75) voteTallyContainer.style.backgroundColor = '#e6f3e6';
else if (numericPercent >= 65) voteTallyContainer.style.backgroundColor = '#fff3cd';
else voteTallyContainer.style.backgroundColor = '#ffe6e6';
} else {
voteTallyContainer.style.backgroundColor = '#f8f9fa';
}
voteTallyContainer.style.borderColor = window.getComputedStyle(voteTallyContainer).backgroundColor.replace('rgb', 'rgba').replace(')', ', 0.5)');
voteTallyContainer.classList.remove('error');
}
}).catch(() => {
voteTallyContainer.textContent = 'Error processing vote tally.';
voteTallyContainer.classList.add('error');
});
return stepContainer;
}
function renderStep4Description(selectedOutcome, votes) { /* Prepare RfX Page Wikitext */
const container = createElement('div');
const loadingMsg = createElement('p', { textContent: 'Loading and processing wikitext...' });
container.appendChild(loadingMsg);
let reason = '', topTemplateName = '', isHoldOutcome = false;
switch (selectedOutcome) {
case 'successful': reason = 'successful'; topTemplateName = 'rfap'; break;
case 'unsuccessful': reason = 'Unsuccessful'; topTemplateName = 'rfaf'; break;
case 'successful_cratchat': reason = 'successful'; topTemplateName = 'bdtop|a|p'; break;
case 'unsuccessful_cratchat': reason = 'Unsuccessful'; topTemplateName = 'bdtop|a|f'; break;
case 'withdrawn': reason = 'WD'; topTemplateName = 'rfaf'; break;
case 'notnow': reason = 'NOTNOW'; topTemplateName = 'rfaf'; break;
case 'snow': reason = 'SNOW'; topTemplateName = 'rfaf'; break;
case 'onhold': reason = 'On hold'; topTemplateName = 'rfah'; isHoldOutcome = true; break;
default: return 'This step is not applicable for the selected outcome.';
}
const topTemplateCode = `{{subst:${topTemplateName}}}`;
fetchRfXWikitext().then(wikitext => {
loadingMsg.remove();
if (wikitext === null || wikitextErrorOccurred) {
container.appendChild(createElement('p', { innerHTML: '<strong>Error:</strong> Could not fetch or process the page wikitext. Please perform the template replacements manually. Check console for details.', style: { color: 'red' } }));
container.appendChild(createElement('p', { innerHTML: `You will need to manually add <code>${topTemplateCode}</code> at the top and potentially perform other closing steps.` }));
return;
}
let modifiedWikitext = wikitext;
modifiedWikitext = modifiedWikitext.replace(/\{\{(subst:)?(rfap|rfaf|rfah|Rfa withdrawn|Rfa snow)\}\}\s*\n?/i, ''); // Remove existing top templates
modifiedWikitext = topTemplateCode + "\n" + modifiedWikitext; // Add new top template
const finaltallyTemplate = `'''Final <span id="rfatally">(?/?/?)</span>; ended by [[User:Barkeep49|Barkeep49]] ([[User_talk:Barkeep49|talk]]) at 03:18, 17 April 2025 (UTC)''' <!-- Template:finaltally -->}`;
const footerTemplate = `
:''The above adminship discussion is preserved as an archive of the discussion. <span style="color:red">'''Please do not modify it.'''</span> Subsequent comments should be made on the appropriate discussion page (such as the talk page of either [[{{NAMESPACE}} talk:{{PAGENAME}}|this nomination]] or the nominated user). No further edits should be made to this page.''</div>__ARCHIVEDTALK__ __NOEDITSECTION__
`;
const chatLink = `*See [[/Bureaucrat chat]].`;
const escapedUsernamePattern = escapeRegex(actualCandidateUsername);
const headerLineRegex = new RegExp(`^(\\s*===\\s*.*?(?:\\[\\[(?:User:${escapedUsernamePattern}|${escapedUsernamePattern})\\|${escapedUsernamePattern}\\]\\]|${escapedUsernamePattern}).*?\\s*===\\s*$)`, 'mi');
let headerMatch = modifiedWikitext.match(headerLineRegex);
let contentReplaced = false;
if (headerMatch) {
const headerEndIndex = headerMatch.index + headerMatch[0].length;
const nextMarkerRegex = /\n(\s*(?:(?:={2,}.*={2,}$)|(?:'''\[\[Wikipedia:Requests for adminship#Monitors\|Monitors\]\]''':)))/m;
const searchStringAfterHeader = modifiedWikitext.substring(headerEndIndex);
const nextMarkerMatch = searchStringAfterHeader.match(nextMarkerRegex);
if (nextMarkerMatch) {
const nextMarkerStartIndex = headerEndIndex + nextMarkerMatch.index;
const textBeforeHeaderEnd = modifiedWikitext.substring(0, headerEndIndex);
const textAfterMarkerStart = modifiedWikitext.substring(nextMarkerStartIndex);
const replacementContent = isHoldOutcome ? `\n${chatLink}\n${finaltallyTemplate}\n` : `\n${finaltallyTemplate}\n`;
modifiedWikitext = textBeforeHeaderEnd.trimEnd() + replacementContent + textAfterMarkerStart;
contentReplaced = true;
} else { console.warn("RfX Closer: Could not find next section marker after header."); }
} else { console.warn("RfX Closer: Could not find candidate header line."); }
if (contentReplaced) {
if (!isHoldOutcome) {
modifiedWikitext = modifiedWikitext.replace(/\{\{(subst:)?rfab\}\}\s*$/i, ''); // Remove existing footer
modifiedWikitext = modifiedWikitext.trim() + "\n" + footerTemplate; // Add new footer
}
const introText = isHoldOutcome
? `Ensure <code>${topTemplateCode}</code> is at the top, and the 'Bureaucrat chat' link and final tally are placed correctly below the header.`
: `Use the links below to copy the full generated wikitext for this RfX page or open it directly in the editor. <strong>Review carefully before saving.</strong>`;
container.appendChild(createElement('p', { innerHTML: introText }));
container.appendChild(createActionLinks(config.pageName, modifiedWikitext, 'Full RfX Page'));
} else {
container.appendChild(createElement('p', { innerHTML: '<strong>Warning:</strong> Could not automatically find the section between the candidate header and the next section/Monitors line to replace. Manual replacement needed. Check console for details.', style: { color: 'orange' } }));
const manualContent = isHoldOutcome ? `${chatLink}\n${finaltallyTemplate}` : finaltallyTemplate;
container.appendChild(createElement('p', { innerHTML: `Please manually replace the content between the candidate header and the next section with:` }));
container.appendChild(createCopyableBox(manualContent, 'Copy content to insert manually'));
if (!isHoldOutcome) {
container.appendChild(createElement('p', { innerHTML: `Also ensure <code>${footerTemplate}</code> is at the very bottom.` }));
}
container.appendChild(createActionLinks(config.pageName, modifiedWikitext, 'RfX Page (Manual Edit Needed)'));
}
}).catch(err => {
loadingMsg.remove();
container.appendChild(createElement('p', { innerHTML: '<strong>Error:</strong> Error processing wikitext. Check console.', style: { color: 'red' } }));
console.error("RfX Closer: Error in wikitext processing chain:", err);
});
return container;
}
function renderStep5Description() { /* Start Bureaucrat Chat */
const chatPageName = `${config.pageName}/Bureaucrat chat`;
const chatPageDisplay = `${config.candidateSubpage}/Bureaucrat chat`;
const container = createElement('div');
container.appendChild(createElement('p', {
innerHTML: `Create the bureaucrat chat page at <a href="${mw.util.getUrl(chatPageName)}" target="_blank">[[${chatPageDisplay}]]</a> with the following content. You can add an optional initial comment below.`
}));
container.appendChild(createElement('label', {
textContent: 'Optional initial comment (will be added under == Discussion ==):',
htmlFor: 'rfx-crat-chat-textarea', // Link label to textarea
style: { display: 'block', marginBottom: '4px' }
}));
const commentTextarea = createElement('textarea', {
id: 'rfx-crat-chat-textarea', // Added ID
className: 'rfx-crat-chat-textarea',
placeholder: `e.g., Initiating discussion per RfX hold... [[User:Barkeep49|Barkeep49]] ([[User_talk:Barkeep49|talk]]) 03:18, 17 April 2025 (UTC)` // Use config.userName
});
container.appendChild(commentTextarea);
const getChatPageWikitext = () => {
const initialComment = commentTextarea.value.trim();
return `{{Bureaucrat discussion header}}\n\n== Discussion ==\n${initialComment}\n\n== Recusals ==\n\n== Summary ==`;
};
container.appendChild(createActionLinks(chatPageName, getChatPageWikitext, 'Bureaucrat Chat Page', true));
return container;
}
function renderStep6Description() { /* Notify Bureaucrats */
const container = createElement('div');
const chatPageName = `${config.pageName}/Bureaucrat chat`;
const chatPageLink = `[[${chatPageName}|bureaucrat chat]]`;
container.appendChild(createElement('p', { innerHTML: `Notify bureaucrats about the new chat page. Edit the message and the list of bureaucrats below if needed, then use the API button.` }));
// Bureaucrat List Textarea
container.appendChild(createElement('label', {
htmlFor: config.selectors.cratListTextarea.substring(1), // Remove # for htmlFor
textContent: 'Bureaucrats to Notify (edit list as needed, one username per line):',
style: { display: 'block', marginTop: '10px', marginBottom: '4px' }
}));
const cratListTextarea = createElement('textarea', {
id: config.selectors.cratListTextarea.substring(1), // Remove # for ID
className: 'rfx-onhold-notify-textarea',
rows: 6,
value: config.hardcodedCrats.join('\n') // Use config
});
container.appendChild(cratListTextarea);
// Notification Message Textarea
container.appendChild(createElement('label', {
htmlFor: config.selectors.cratNotifyMessage.substring(1),
textContent: 'Notification Message:',
style: { display: 'block', marginTop: '10px', marginBottom: '4px' }
}));
const messageTextarea = createElement('textarea', {
id: config.selectors.cratNotifyMessage.substring(1),
className: 'rfx-onhold-notify-textarea',
value: `== Bureaucrat Chat ==\nYour input is requested at the freshly-created ${chatPageLink} for [[${config.pageName}]]. [[User:Barkeep49|Barkeep49]] ([[User_talk:Barkeep49|talk]]) 03:18, 17 April 2025 (UTC)`
});
container.appendChild(messageTextarea);
// Button and Status Area
const buttonContainer = createElement('div');
const postButton = createActionButton(config.selectors.cratNotifyButton.substring(1), 'Notify Bureaucrats via API', handleNotifyCratsClick); // Pass handler
const statusArea = createStatusArea(config.selectors.cratNotifyStatus.substring(1), 'rfx-crat-notify-status');
buttonContainer.appendChild(postButton);
buttonContainer.appendChild(statusArea);
container.appendChild(buttonContainer);
// Initial Status Update Logic
const updateInitialStatus = () => {
const bureaucratsToNotify = cratListTextarea.value.trim().split('\n').map(name => name.trim()).filter(name => name);
if (bureaucratsToNotify.length > 0) {
statusArea.textContent = `Ready to notify ${bureaucratsToNotify.length} bureaucrats from the list.`;
postButton.disabled = false;
} else {
statusArea.textContent = 'Bureaucrat list is currently empty.';
postButton.disabled = true;
}
statusArea.style.color = 'inherit';
};
cratListTextarea.addEventListener('input', updateInitialStatus);
updateInitialStatus(); // Set initial state
return container;
}
function renderStep7Description() { /* Notify Candidate (On Hold) */
const container = createElement('div');
const candidateTalkPage = `User talk:${actualCandidateUsername}`;
const chatPageName = `${config.pageName}/Bureaucrat chat`;
const chatPageLink = `[[${chatPageName}|bureaucrat chat]]`;
container.appendChild(createElement('p', { innerHTML: `Notify the candidate (${actualCandidateUsername}) about the 'on hold' status.` }));
const messageTextarea = createElement('textarea', {
id: config.selectors.candidateOnholdNotifyMessage.substring(1),
className: 'rfx-onhold-notify-textarea',
value: `== Your ${config.rfxType === 'adminship' ? 'RfA' : 'RfB'} ==\nHi ${actualCandidateUsername}, just letting you know that your ${config.rfxType} request has been placed on hold pending discussion amongst the bureaucrats. You can follow the discussion at the ${chatPageLink}. [[User:Barkeep49|Barkeep49]] ([[User_talk:Barkeep49|talk]]) 03:18, 17 April 2025 (UTC)`
});
container.appendChild(messageTextarea);
const buttonContainer = createElement('div');
const postButton = createActionButton(config.selectors.candidateOnholdNotifyButton.substring(1), 'Post Notification via API', handleNotifyCandidateOnholdClick); // Pass handler
const manualEditLink = createElement('a', {
href: mw.util.getUrl(candidateTalkPage, { action: 'edit', section: 'new', sectiontitle: `Your ${config.rfxType === 'adminship' ? 'RfA' : 'RfB'}` }),
target: '_blank',
className: 'rfx-notify-editlink',
textContent: `Post Manually...`
});
const statusArea = createStatusArea(config.selectors.candidateOnholdNotifyStatus.substring(1), 'rfx-candidate-onhold-notify-status');
buttonContainer.appendChild(postButton);
buttonContainer.appendChild(manualEditLink);
buttonContainer.appendChild(statusArea);
container.appendChild(buttonContainer);
return container;
}
async function renderStep8Description() { /* Process Promotion */
const container = createElement('div');
container.appendChild(createElement('p', { textContent: 'For successful RfXs, configure rights changes and grant via API:' }));
const loadingStatus = createElement('p', { textContent: 'Loading current user groups...', style: { fontStyle: 'italic' } });
container.appendChild(loadingStatus);
const rightsContainer = createElement('div');
container.appendChild(rightsContainer);
const grantButton = createActionButton('rfx-grant-rights-button', 'Grant Rights via API'); // ID added
grantButton.disabled = true;
const statusArea = createStatusArea('rfx-grant-rights-status'); // ID added
let removeCheckboxes = {}; // To store OOUI checkbox widgets
try {
const currentGroups = await getUserGroups(actualCandidateUsername);
loadingStatus.remove();
if (currentGroups === null) {
statusArea.textContent = 'Error: Could not load user groups. Cannot proceed.';
statusArea.style.color = 'red';
} else {
const groupToAdd = config.rfxType === 'adminship' ? 'sysop' : 'bureaucrat';
const groupToAddLabel = config.rfxType === 'adminship' ? 'Administrator' : 'Bureaucrat';
// Use OOUI for checkboxes
const addFieldset = new OO.ui.FieldsetLayout({ label: 'Group to Add' });
const addCheckbox = new OO.ui.CheckboxInputWidget({ selected: true, disabled: true, classes: ['rfx-closer-checkbox'] });
addFieldset.addItems([new OO.ui.FieldLayout(addCheckbox, { label: groupToAddLabel, align: 'inline' })]);
rightsContainer.appendChild(addFieldset.$element[0]);
if (config.rfxType === 'adminship') {
const removeFieldset = new OO.ui.FieldsetLayout({ label: 'Existing rights (Uncheck to keep)' });
const groupsToExclude = ['*', 'user', 'autoconfirmed', groupToAdd, 'importer', 'transwiki', 'researcher', 'checkuser', 'suppress'];
removeCheckboxes = {}; // Reset
let hasRemovable = false;
currentGroups.forEach(groupName => {
if (!groupsToExclude.includes(groupName)) {
hasRemovable = true;
const checkbox = new OO.ui.CheckboxInputWidget({ selected: true });
removeCheckboxes[groupName] = checkbox; // Store the widget
const displayLabel = config.groupDisplayNames[groupName] || groupName;
const field = new OO.ui.FieldLayout(checkbox, { label: displayLabel, align: 'inline' });
removeFieldset.addItems([field]);
}
});
if (hasRemovable) {
rightsContainer.appendChild(removeFieldset.$element[0]);
} else {
rightsContainer.appendChild(createElement('p', { textContent: 'User holds no other explicit groups to potentially remove.', style: { fontSize: '0.9em', fontStyle: 'italic' } }));
}
} else {
rightsContainer.appendChild(createElement('p', { textContent: 'No groups removed when granting bureaucrat rights.', style: { fontSize: '0.9em', fontStyle: 'italic' } }));
}
const userGroups = mw.config.get('wgUserGroups');
const canGrant = userGroups && userGroups.includes('bureaucrat');
if (canGrant) {
grantButton.disabled = false;
grantButton.addEventListener('click', () => handleGrantRightsClick(removeCheckboxes)); // Pass checkboxes map
} else {
statusArea.textContent = 'You lack bureaucrat rights to use the API.'; statusArea.style.color = 'orange';
}
}
} catch (error) {
loadingStatus.remove();
statusArea.textContent = `Error loading user groups: ${error.message || 'Unknown API error'}.`;
statusArea.style.color = 'red';
}
container.appendChild(grantButton);
container.appendChild(statusArea);
const list = createElement('ul', { style: { paddingLeft: '20px', marginTop: '10px' } }, [
createElement('li', { innerHTML: `Manual link: <a href="/wiki/Special:Userrights/${encodeURIComponent(actualCandidateUsername)}" target="_blank">Special:Userrights/${actualCandidateUsername}</a>` }),
createElement('li', { innerHTML: `Reference: <a href="/wiki/Special:ListGroupRights" target="_blank">Special:ListGroupRights</a>` })
]);
container.appendChild(list);
return container;
}
async function renderStep9Description(selectedOutcome, votes) { /* Update Lists */
const container = createElement('div');
// --- 1. Remove from main RfX list page ---
const removeDiv = createElement('div', { style: { marginBottom: '15px' } });
const removeLinkText = config.displayBaseRfxPage;
removeDiv.appendChild(createElement('p', { innerHTML: `<strong>1. First, remove entry from ${removeLinkText}:</strong>` }));
const removeLoadingPara = createElement('p', { textContent: ` Loading wikitext for ${removeLinkText}...`, style: { fontStyle: 'italic' } });
removeDiv.appendChild(removeLoadingPara);
container.appendChild(removeDiv);
try {
const basePageWikitext = await fetchPageWikitext(config.baseRfxPage);
removeLoadingPara.remove();
if (basePageWikitext !== null) {
const escapedPageNameForRegex = config.pageName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const lineToRemoveRegex = new RegExp(`^.*?\\{\\{(?:${escapedPageNameForRegex.replace(/_/g, '[_ ]')})\\}\\}.*?$\\n(?:^----\\s*\\n)?`, 'gmi');
const modifiedBasePageWikitext = basePageWikitext.replace(lineToRemoveRegex, '');
if (modifiedBasePageWikitext.length < basePageWikitext.length) {
removeDiv.appendChild(createActionLinks(config.baseRfxPage, modifiedBasePageWikitext, removeLinkText));
} else {
removeDiv.appendChild(createElement('p', { innerHTML: ` Warning: Could not find <code>{{${config.pageName}}}</code> to remove from ${removeLinkText}. Remove manually.`, style: { color: 'orange' } }));
removeDiv.appendChild(createActionLinks(config.baseRfxPage, basePageWikitext, removeLinkText)); // Still provide link to edit
}
} else { throw new Error(`Failed to fetch wikitext for ${config.baseRfxPage}`); }
} catch (error) {
console.error("RfX Closer: Error processing base page wikitext:", error);
removeLoadingPara.remove();
removeDiv.appendChild(createElement('p', { textContent: ` Error loading wikitext for ${removeLinkText}. Edit manually.`, style: { color: 'red' } }));
}
// --- 2. Add to outcome lists ---
const addDiv = createElement('div'); // Container for list updates
addDiv.appendChild(createElement('p', { innerHTML: `<strong>2. Then, add entry to appropriate list(s):</strong>` }));
let generatedListEntry = null, generatedRfarowTemplate = null, yearlyListPageName = '', alphabeticalListPageName = '', isSuccessfulList = false;
const months = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
const now = new Date(); let day = now.getDate(); let month = months[now.getMonth()]; let year = now.getFullYear(); let formattedDate = `${day} ${month} ${year}`;
// Attempt to parse date from fetched data
if (rfaData && rfaData.endTime && rfaData.endTime !== 'N/A') {
try { const dateParts = rfaData.endTime.match(/(\d{1,2}) (\w+) (\d{4})/); if (dateParts) { formattedDate = `${dateParts[1]} ${dateParts[2]} ${dateParts[3]}`; day = parseInt(dateParts[1], 10); month = dateParts[2]; year = parseInt(dateParts[3], 10); } else { console.warn("Could not parse DD Month YYYY from endTime:", rfaData.endTime); } } catch (e) { console.warn("Error parsing endTime:", rfaData.endTime, e); }
}
// Determine list pages and entry formats based on outcome
if (selectedOutcome === 'successful') {
isSuccessfulList = true;
yearlyListPageName = `Wikipedia:Successful ${config.rfxType} candidacies/${year}`;
generatedRfarowTemplate = `|{{rfarow|${actualCandidateUsername}||${formattedDate}|successful|${votes.support}|${votes.oppose}|${votes.neutral}|${config.userName}}}`;
} else if (['unsuccessful', 'withdrawn', 'notnow', 'snow'].includes(selectedOutcome)) {
isSuccessfulList = false;
let reasonText = 'Unsuccessful', rfarowResult = 'unsuccessful';
switch(selectedOutcome) {
case 'withdrawn': reasonText = 'Withdrawn'; rfarowResult = 'Withdrawn'; break;
case 'notnow': reasonText = '[[WP:NOTNOW|NOTNOW]]'; rfarowResult = 'NOTNOW'; break;
case 'snow': reasonText = '[[WP:SNOW|SNOW]]'; rfarowResult = 'SNOW'; break;
}
yearlyListPageName = `Wikipedia:Unsuccessful ${config.rfxType} candidacies (Chronological)/${year}`;
const firstLetter = actualCandidateUsername.charAt(0).toUpperCase();
let alphaBase = `Wikipedia:Unsuccessful ${config.rfxType} candidacies`;
if (config.rfxType === 'adminship') { alphabeticalListPageName = (firstLetter >= 'A' && firstLetter <= 'Z') ? `${alphaBase}/${firstLetter}` : `${alphaBase} (Alphabetical)`; }
else { alphabeticalListPageName = `${alphaBase} (Alphabetical)`; }
const isSubsequentNomination = /[_ ]\d+$/.test(config.pageName);
generatedListEntry = `${isSubsequentNomination ? '*:' : '*'} [[${config.pageName}|${actualCandidateUsername}]] ${formattedDate} - ${reasonText} ([[User:${config.userName}|${config.userName}]]) (${votes.support}/${votes.oppose}/${votes.neutral})`;
generatedRfarowTemplate = `|{{rfarow|${actualCandidateUsername}||${formattedDate}|${rfarowResult}|${votes.support}|${votes.oppose}|${votes.neutral}|${config.userName}}}`;
}
// --- Handle Yearly Page Update ---
if (yearlyListPageName && generatedRfarowTemplate) {
const yearlyDiv = createElement('div', { style: { marginTop: '10px' } });
yearlyDiv.appendChild(createElement('p', { innerHTML: `2a. Update <a href="/wiki/${yearlyListPageName}" target="_blank">${yearlyListPageName}</a>:` }));
yearlyDiv.appendChild(createElement('div', { className: 'rfx-closer-known-issue', innerHTML: `<strong>Known Issue:</strong> The script may fail to automatically uncomment the correct month header if it's currently commented out. Please double-check the yearly list page and manually uncomment the header (e.g., remove <code><!--</code> and <code>--></code>) if needed before saving.` }));
const yearlyLoading = createElement('p', { textContent: ` Loading...`, style: { fontStyle: 'italic' } });
yearlyDiv.appendChild(yearlyLoading);
addDiv.appendChild(yearlyDiv);
try {
const yearlyWikitext = await fetchPageWikitext(yearlyListPageName);
yearlyLoading.remove();
if (yearlyWikitext !== null) {
let modifiedYearlyWikitext = yearlyWikitext; let modificationPerformed = false; let noteText = ''; let entryAdded = false; let countUpdated = false; let uncommentAttempted = false;
const countRegex = isSuccessfulList ? /(\'\'\'?)(\d+)\s+successful candidacies so far(\'\'\'?)/i : /(\'\'\'?)(\d+)\s+unsuccessful candidacies so far(\'\'\'?)/i;
const originalWikitextForCountCheck = modifiedYearlyWikitext;
modifiedYearlyWikitext = updateCountInWikitext(modifiedYearlyWikitext, countRegex, 1);
if (modifiedYearlyWikitext !== originalWikitextForCountCheck) { modificationPerformed = true; countUpdated = true; } else { console.warn(`RfX Closer: Could not update count on ${yearlyListPageName}`); }
const escapedMonth = month.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
// Updated regex to match both commented and uncommented month headers
const monthHeaderRegex = new RegExp(`^(?:<!--)?\\|-\\s*\\n\\|\\s*colspan="[\\d]+"\\s*style="background:#ffdead;"\\s*\\|\\s*'''\\{\\{Anchord\\|${escapedMonth}\\s+${year}\\}\\}'''.*?$(?:\\n-->)?`, 'mi');
let insertionIndex = -1;
let headerFound = false;
let originalWikitextForInsertionCheck = modifiedYearlyWikitext;
let headerMatch = modifiedYearlyWikitext.match(monthHeaderRegex);
if (headerMatch) {
headerFound = true;
const fullMatchText = headerMatch[0];
const headerStartIndex = headerMatch.index;
const isCommented = fullMatchText.startsWith('<!--');
if (isCommented) {
uncommentAttempted = true;
console.warn(`RfX Closer: Attempting to uncomment month header for ${month} ${year}.`);
// Find the end of the commented section
const commentEndIndex = modifiedYearlyWikitext.indexOf('-->', headerStartIndex);
if (commentEndIndex !== -1) {
// Extract the content between <!-- and -->
const commentedContent = modifiedYearlyWikitext.substring(headerStartIndex + 4, commentEndIndex);
// Replace the entire commented section with the uncommented content
modifiedYearlyWikitext = modifiedYearlyWikitext.substring(0, headerStartIndex) +
commentedContent +
modifiedYearlyWikitext.substring(commentEndIndex + 3);
modificationPerformed = true;
// Find the next |- after the header
const nextRowIndex = modifiedYearlyWikitext.indexOf('|-', headerStartIndex);
insertionIndex = nextRowIndex !== -1 ? nextRowIndex : headerStartIndex + commentedContent.length;
} else {
headerFound = false;
console.error(`RfX Closer: Failed to find end of comment for ${month} ${year} header.`);
}
} else {
// For uncommented headers, find the next |- or end of the section
const nextRowIndex = modifiedYearlyWikitext.indexOf('|-', headerStartIndex + fullMatchText.length);
insertionIndex = nextRowIndex !== -1 ? nextRowIndex : headerStartIndex + fullMatchText.length;
}
} else {
headerFound = false;
console.warn(`RfX Closer: Could not find header for ${month} ${year} on ${yearlyListPageName}.`);
}
if (headerFound && insertionIndex !== -1) {
// Ensure we have a newline before inserting
if (insertionIndex > 0 && modifiedYearlyWikitext[insertionIndex - 1] !== '\n') {
modifiedYearlyWikitext = modifiedYearlyWikitext.substring(0, insertionIndex) + '\n' + modifiedYearlyWikitext.substring(insertionIndex);
insertionIndex++;
}
const insertionContent = '|-\n' + generatedRfarowTemplate + '\n';
modifiedYearlyWikitext = modifiedYearlyWikitext.substring(0, insertionIndex) + insertionContent + modifiedYearlyWikitext.substring(insertionIndex);
if (modifiedYearlyWikitext !== originalWikitextForInsertionCheck) {
modificationPerformed = true;
}
entryAdded = true;
console.log(`RfX Closer: Entry added to ${yearlyListPageName} at position ${insertionIndex}.`);
} else if (!headerFound) {
console.warn(`RfX Closer: Row not inserted into yearly list because header was not found.`);
}
noteText = ` `;
if (countUpdated && entryAdded) { noteText += `Count updated and entry added.`; }
else if (countUpdated && !entryAdded) { noteText += `Count updated (could not add entry).`; }
else if (!countUpdated && entryAdded) { noteText += `Entry added (could not update count).`; }
else if (!countUpdated && !entryAdded) { noteText = ` Could not automatically update ${yearlyListPageName}.`; }
if (uncommentAttempted && !entryAdded) { noteText += ` (Failed to uncomment month header or insert entry).`; }
else if (uncommentAttempted) { noteText += ` (Attempted to uncomment month header - please verify).`; }
yearlyDiv.appendChild(createElement('p', { innerHTML: noteText }));
if (modificationPerformed) {
yearlyDiv.appendChild(createActionLinks(yearlyListPageName, modifiedYearlyWikitext, yearlyListPageName));
}
if (!entryAdded) { yearlyDiv.appendChild(createCopyableBox(generatedRfarowTemplate, `Copy row template to manually insert`)); }
} else { throw new Error(`Failed to fetch wikitext for ${yearlyListPageName}`); }
} catch (error) {
console.error("RfX Closer: Error processing yearly list:", error);
yearlyLoading.remove();
yearlyDiv.appendChild(createElement('p', { innerHTML: ` Error processing ${yearlyListPageName}. Update manually.`, style: { color: 'red' } }));
if (generatedRfarowTemplate) { yearlyDiv.appendChild(createCopyableBox(generatedRfarowTemplate, `Copy row template to manually insert`)); }
}
}
// --- Handle Alphabetical List Page Update ---
if (alphabeticalListPageName && !isSuccessfulList && generatedListEntry) {
const alphaDiv = createElement('div', { style: { marginTop: '15px' } });
alphaDiv.appendChild(createElement('p', { innerHTML: `2b. Update <a href="/wiki/${alphabeticalListPageName}" target="_blank">${alphabeticalListPageName}</a>:` }));
const alphaLoading = createElement('p', { textContent: ` Loading...`, style: { fontStyle: 'italic' } });
alphaDiv.appendChild(alphaLoading);
addDiv.appendChild(alphaDiv);
try {
const alphaWikitext = await fetchPageWikitext(alphabeticalListPageName);
alphaLoading.remove();
if (alphaWikitext !== null) {
const lines = alphaWikitext.split('\n'); let insertionLineIndex = -1; const newCandidateName = actualCandidateUsername; const lineRegex = /^\s*([*:]*)\s*\[\[(?:[^|]+\|)?([^\]]+)\]\]/i;
for (let i = 0; i < lines.length; i++) { const line = lines[i]; const match = line.match(lineRegex); if (match) { const existingUsername = match[2].trim(); if (newCandidateName.localeCompare(existingUsername, undefined, { sensitivity: 'base' }) < 0) { insertionLineIndex = i; break; } } }
let modifiedAlphaWikitext; let alphaNoteText = ` `;
if (insertionLineIndex !== -1) { lines.splice(insertionLineIndex, 0, generatedListEntry); modifiedAlphaWikitext = lines.join('\n'); alphaNoteText += `Entry inserted alphabetically.`; }
else { let lastRelevantIndex = -1; for (let i = lines.length - 1; i >= 0; i--) { if (lines[i].match(lineRegex)) { lastRelevantIndex = i; break; } } if (lastRelevantIndex !== -1) { lines.splice(lastRelevantIndex + 1, 0, generatedListEntry); } else { if (alphaWikitext.length > 0 && !alphaWikitext.endsWith('\n')) { lines.push(''); } lines.push(generatedListEntry); } modifiedAlphaWikitext = lines.join('\n'); alphaNoteText += `Entry appended.`; }
alphaDiv.appendChild(createElement('p', { innerHTML: alphaNoteText }));
alphaDiv.appendChild(createActionLinks(alphabeticalListPageName, modifiedAlphaWikitext, alphabeticalListPageName));
} else { throw new Error(`Failed to fetch wikitext for ${alphabeticalListPageName}`); }
} catch (error) {
console.error("RfX Closer: Error processing alphabetical list:", error);
alphaLoading.remove();
alphaDiv.appendChild(createElement('p', { innerHTML: ` Error processing ${alphabeticalListPageName}. Update manually.`, style: { color: 'red' } }));
if (generatedListEntry) { alphaDiv.appendChild(createCopyableBox(generatedListEntry, `Copy entry to manually insert`)); }
}
}
container.appendChild(addDiv);
return container;
}
function renderStep10Description(selectedOutcome) { /* Notify Candidate (Closing) */
if (selectedOutcome === 'onhold') {
return '(Notification for "on hold" is handled in Step 7.)';
}
const container = createElement('div');
const candidateTalkPage = `User talk:${actualCandidateUsername}`;
const sectionTitle = `Outcome of your ${config.rfxType === 'adminship' ? 'RfA' : 'RfB'}`;
const editUrl = mw.util.getUrl(candidateTalkPage, { action: 'edit', section: 'new', sectiontitle: sectionTitle });
container.appendChild(createElement('p', { innerHTML: `Prepare message for <a href="/wiki/${encodeURIComponent(candidateTalkPage)}" target="_blank">${candidateTalkPage}</a>.` }));
const optionsDiv = createElement('div', { className: 'rfx-notify-options' });
container.appendChild(optionsDiv);
const messageTextarea = createElement('textarea', { className: 'rfx-notify-textarea', rows: 5 });
const copyBoxPlaceholder = createElement('div'); // Placeholder for copy box/links
const buttonContainer = createElement('div', { style: { marginTop: '10px' } });
const statusArea = createStatusArea('rfx-notify-status-closing', 'rfx-notify-status'); // New ID
let messageContentGetter = () => ''; // Function to get current message content
if (selectedOutcome === 'successful') {
const templateName = config.rfxType === 'adminship' ? 'New sysop' : 'New bureaucrat';
const defaultMessage = `{{subst:${templateName}}} [[User:${config.userName}|${config.userName}]] ([[User talk:${config.userName}|talk]]) 03:18, 17 April 2025 (UTC)`;
const radioTemplateId = 'rfx-notify-template-radio';
const radioCustomId = 'rfx-notify-custom-radio';
const radioTemplate = createElement('input', { type: 'radio', name: 'rfx-notify-choice', id: radioTemplateId, value: 'template', checked: true });
const radioCustom = createElement('input', { type: 'radio', name: 'rfx-notify-choice', id: radioCustomId, value: 'custom' });
optionsDiv.appendChild(createElement('label', { htmlFor: radioTemplateId }, [radioTemplate, ` Use {{${templateName}}}`]));
optionsDiv.appendChild(createElement('label', { htmlFor: radioCustomId }, [radioCustom, ' Use custom message']));
messageTextarea.value = `Congratulations! Your ${config.rfxType === 'adminship' ? 'RfA' : 'RfB'} was successful. [[User:${config.userName}|${config.userName}]] ([[User talk:${config.userName}|talk]]) 03:18, 17 April 2025 (UTC)~`;
messageTextarea.style.display = 'none';
container.appendChild(messageTextarea);
container.appendChild(copyBoxPlaceholder);
const updateSuccessActions = () => {
copyBoxPlaceholder.innerHTML = ''; // Clear previous
if (radioTemplate.checked) {
messageContentGetter = () => defaultMessage;
copyBoxPlaceholder.appendChild(createActionLinks(null, defaultMessage, `{{${templateName}}}`)); // No edit link needed
} else {
messageContentGetter = () => messageTextarea.value;
copyBoxPlaceholder.appendChild(createCopyableBox(null, 'Copy the message above', false, messageTextarea));
}
};
radioTemplate.addEventListener('change', () => { messageTextarea.style.display = 'none'; updateSuccessActions(); });
radioCustom.addEventListener('change', () => { messageTextarea.style.display = 'block'; updateSuccessActions(); });
updateSuccessActions(); // Initial call
} else { // Unsuccessful outcomes
let reasonPhrase = 'unsuccessful';
switch(selectedOutcome) {
case 'withdrawn': reasonPhrase = 'withdrawn by candidate'; break;
case 'notnow': reasonPhrase = 'closed as [[WP:NOTNOW|NOTNOW]]'; break;
case 'snow': reasonPhrase = 'closed per [[WP:SNOW|SNOW]]'; break;
}
const defaultMessage = `Hi ${actualCandidateUsername}. I'm ${config.userName} and I have closed your ${config.rfxType === 'adminship' ? 'RfA' : 'RfB'} as ${reasonPhrase}. Thanks for submitting your candidacy. I hope the feedback left by editors is useful and know that many people have successfully gained ${config.rfxType === 'adminship' ? 'administrator' : 'bureaucrat'} rights after initially being unsuccessful. [[User:Barkeep49|Barkeep49]] ([[User_talk:Barkeep49|talk]]) 03:18, 17 April 2025 (UTC)`;
messageTextarea.value = defaultMessage;
container.appendChild(messageTextarea);
container.appendChild(copyBoxPlaceholder);
messageContentGetter = () => messageTextarea.value;
copyBoxPlaceholder.appendChild(createCopyableBox(null, 'Copy the message above', false, messageTextarea));
}
// --- API Post Button ---
const postButton = createActionButton('rfx-notify-candidate-closing-button', 'Post Notification via API', () => handleNotifyCandidateClosingClick(messageContentGetter)); // New ID, pass getter
buttonContainer.appendChild(postButton);
// Add Manual Edit Link
const manualEditLink = createElement('a', { href: editUrl, target: '_blank', className: 'rfx-notify-editlink', textContent: `Post Manually...` });
buttonContainer.appendChild(manualEditLink);
container.appendChild(buttonContainer);
container.appendChild(statusArea);
return container;
}
// --- Step Definitions Array ---
const steps = [
{ title: 'Check Timing', description: renderStep0Description, completed: false }, // 0
{ title: 'Verify History', description: renderStep1Description, completed: false }, // 1
{ title: 'Determine Consensus', description: renderStep2Description, completed: false, isConsensusStep: true }, // 2
{ title: 'Select Outcome', description: 'Based on the consensus determination, select the appropriate outcome:', completed: false, isSelectionStep: true }, // 3
{ title: 'Prepare RfX Page Wikitext', description: renderStep4Description, completed: false, showFor: ['successful', 'unsuccessful', 'successful_cratchat', 'unsuccessful_cratchat', 'withdrawn', 'notnow', 'snow', 'onhold'] }, // 4
{ title: 'Start Bureaucrat Chat', description: renderStep5Description, completed: false, showFor: ['onhold'] }, // 5
{ title: 'Notify Bureaucrats', description: renderStep6Description, completed: false, showFor: ['onhold'] }, // 6
{ title: 'Notify Candidate (On Hold)', description: renderStep7Description, completed: false, showFor: ['onhold'] }, // 7
{ title: 'Process Promotion', description: renderStep8Description, completed: false, showFor: ['successful', 'successful_cratchat'] }, // 8
{ title: 'Update Lists', description: renderStep9Description, completed: false, showFor: ['successful', 'unsuccessful', 'successful_cratchat', 'unsuccessful_cratchat', 'withdrawn', 'notnow', 'snow'] }, // 9
{ title: 'Notify Candidate (Closing)', description: renderStep10Description, completed: false, showFor: ['successful', 'unsuccessful', 'successful_cratchat', 'unsuccessful_cratchat', 'withdrawn', 'notnow', 'snow'] } // 10
];
// --- Step Rendering and Logic ---
/** Renders a single step based on current state. */
function renderStep(step, index, selectedOutcome = '', currentVotes = {}) {
const stepElement = createElement('div', {
className: 'rfx-closer-step',
dataset: { step: index, completed: step.completed }
});
const titleElement = createElement('h3');
const descriptionContainer = createElement('div', { className: 'rfx-closer-step-description' });
const displayIndex = index + 1; // 1-based index for display
if (step.isSelectionStep) {
titleElement.textContent = `${displayIndex}. ${step.title}`;
descriptionContainer.textContent = step.description;
const templateSelector = createElement('select', { id: config.selectors.outcomeSelector.substring(1) }); // Remove # for ID
templateSelector.innerHTML = `
<option value="" disabled selected>-- Select Outcome --</option>
<option value="successful">Successful</option>
<option value="unsuccessful">Unsuccessful</option>
<option value="successful_cratchat">Successful (cratchat)</option>
<option value="unsuccessful_cratchat">Unsuccessful (cratchat)</option>
<option value="withdrawn">Withdrawn</option>
<option value="notnow">NOTNOW</option>
<option value="snow">SNOW</option>
<option value="onhold">On hold</option>`;
templateSelector.addEventListener('change', handleOutcomeChange); // Attach listener
stepElement.appendChild(titleElement);
stepElement.appendChild(descriptionContainer);
stepElement.appendChild(templateSelector);
} else {
const checkbox = createElement('input', { type: 'checkbox', checked: step.completed, dataset: { stepIndex: index } });
checkbox.addEventListener('change', (e) => {
const stepIndex = parseInt(e.target.dataset.stepIndex, 10);
steps[stepIndex].completed = e.target.checked;
stepElement.dataset.completed = steps[stepIndex].completed;
});
titleElement.appendChild(checkbox);
titleElement.appendChild(document.createTextNode(` ${displayIndex}. ${step.title}`)); // Add space
// Handle step description rendering (sync or async)
Promise.resolve().then(() => {
if (typeof step.description === 'function') {
if (!step.showFor || step.showFor.includes(selectedOutcome)) {
return step.description(selectedOutcome, currentVotes); // Call the specific render function
} else {
// Provide specific messages for hidden steps
if (selectedOutcome === 'onhold' && (index === 9 || index === 10)) return '(This step is not applicable for the "on hold" outcome.)';
if ((selectedOutcome !== 'successful' && selectedOutcome !== 'successful_cratchat') && index === 8) return '(This step is only applicable for successful outcomes.)';
if (selectedOutcome !== 'onhold' && (index === 5 || index === 6 || index === 7)) return '(This step is only applicable for the "on hold" outcome.)';
return 'This step is not applicable for the selected outcome.';
}
} else {
return step.description; // Should not happen with current structure
}
}).then(content => {
descriptionContainer.innerHTML = ''; // Clear previous content
if (content instanceof Node) {
descriptionContainer.appendChild(content);
} else {
descriptionContainer.textContent = content || '';
}
}).catch(error => {
console.error(`Error rendering description for step ${displayIndex}:`, error);
descriptionContainer.textContent = 'Error loading step content. Check console.';
descriptionContainer.style.color = 'red';
});
stepElement.appendChild(titleElement);
stepElement.appendChild(descriptionContainer);
}
// Determine visibility
if (index <= 3) { // Steps 0-3 always show initially
stepElement.style.display = 'block';
} else {
const shouldShow = (index === 4 && selectedOutcome) || (step.showFor && step.showFor.includes(selectedOutcome));
stepElement.style.display = shouldShow ? 'block' : 'none';
}
return stepElement;
}
/** Renders all steps based on the selected outcome. */
function renderAllSteps(selectedOutcome = '', currentVotes = {}) {
const stepsContainerEl = document.querySelector(config.selectors.stepsContainer);
if (!stepsContainerEl) return;
stepsContainerEl.innerHTML = ''; // Clear existing steps
steps.forEach((step, index) => {
const stepElement = renderStep(step, index, selectedOutcome, currentVotes);
stepsContainerEl.appendChild(stepElement);
});
// Re-attach selector value
const selector = document.querySelector(config.selectors.outcomeSelector);
if (selector) selector.value = selectedOutcome;
}
// --- Event Handlers ---
/** Handles clicks on the "Notify Bureaucrats" button (Step 6). */
async function handleNotifyCratsClick() {
const postButton = document.querySelector(config.selectors.cratNotifyButton);
const statusArea = document.querySelector(config.selectors.cratNotifyStatus);
const cratListTextarea = document.querySelector(config.selectors.cratListTextarea);
const messageTextarea = document.querySelector(config.selectors.cratNotifyMessage);
if (!postButton || !statusArea || !cratListTextarea || !messageTextarea) return;
postButton.disabled = true;
statusArea.innerHTML = 'Starting notifications... (This may take a while)<ul id="crat-notify-progress"></ul>';
const progressList = statusArea.querySelector('#crat-notify-progress'); // Get list element
const messageContent = messageTextarea.value.trim();
const editSummary = `Notification: Bureaucrat chat created for [[${config.pageName}]]${config.tagLine}`;
const sectionTitle = `Bureaucrat chat for ${actualCandidateUsername}`;
let successCount = 0, failCount = 0;
const bureaucratsToNotify = cratListTextarea.value.trim().split('\n').map(name => name.trim()).filter(name => name);
if (bureaucratsToNotify.length === 0) {
statusArea.textContent = 'Error: Bureaucrat list is empty.'; statusArea.style.color = 'red';
postButton.disabled = false; if (progressList) progressList.remove(); return;
}
if (!messageContent) {
statusArea.textContent = 'Error: Message cannot be empty.'; statusArea.style.color = 'red';
postButton.disabled = false; if (progressList) progressList.remove(); return;
}
for (const cratUsername of bureaucratsToNotify) {
if (!cratUsername || cratUsername.includes(':') || cratUsername.includes('/') || cratUsername.includes('#')) {
const progressItem = createElement('li', { textContent: `✗ Skipping invalid username: ${cratUsername}`, className: 'warning' });
if (progressList) progressList.appendChild(progressItem);
failCount++; continue;
}
const talkPage = `User talk:${cratUsername}`;
const progressItem = createElement('li', { textContent: `Notifying ${cratUsername}...` });
if (progressList) progressList.appendChild(progressItem);
statusArea.scrollTop = statusArea.scrollHeight;
const result = await postToTalkPage(talkPage, sectionTitle, messageContent, editSummary);
if (result.success) {
progressItem.textContent = `✓ Notified ${cratUsername}`; progressItem.classList.add('success'); successCount++;
} else {
progressItem.textContent = `✗ Failed ${cratUsername}: ${result.error}`; progressItem.classList.add('error'); failCount++;
}
await sleep(300); // Delay
}
const finalStatus = createElement('p', {
textContent: `Finished: ${successCount} successful, ${failCount} failed.`,
style: { fontWeight: 'bold', color: failCount > 0 ? 'orange' : 'green' }
});
statusArea.appendChild(finalStatus);
statusArea.scrollTop = statusArea.scrollHeight;
if (failCount === 0) { // Only mark complete if all succeed
const stepElement = postButton.closest(config.selectors.stepElement);
const stepIndex = parseInt(stepElement?.dataset.step, 10);
if (stepElement && !isNaN(stepIndex) && steps[stepIndex]) {
steps[stepIndex].completed = true; stepElement.dataset.completed = true;
const checkbox = stepElement.querySelector(config.selectors.stepCheckbox); if (checkbox) checkbox.checked = true;
}
postButton.textContent = 'Notifications Sent'; // Keep disabled on full success
} else {
postButton.disabled = false; // Re-enable if some failed
}
}
/** Handles clicks on the "Notify Candidate (On Hold)" button (Step 7). */
async function handleNotifyCandidateOnholdClick() {
const postButton = document.querySelector(config.selectors.candidateOnholdNotifyButton);
const statusArea = document.querySelector(config.selectors.candidateOnholdNotifyStatus);
const messageTextarea = document.querySelector(config.selectors.candidateOnholdNotifyMessage);
if (!postButton || !statusArea || !messageTextarea) return;
postButton.disabled = true;
statusArea.textContent = 'Posting message...'; statusArea.style.color = 'inherit';
const messageContent = messageTextarea.value.trim();
const candidateTalkPage = `User talk:${actualCandidateUsername}`;
const editSummary = `Notifying candidate about RfX hold${config.tagLine}`;
const sectionTitle = `Your ${config.rfxType === 'adminship' ? 'RfA' : 'RfB'}`;
if (!messageContent) {
statusArea.textContent = 'Error: Message cannot be empty.'; statusArea.style.color = 'red';
postButton.disabled = false; return;
}
const result = await postToTalkPage(candidateTalkPage, sectionTitle, messageContent, editSummary);
if (result.success) {
statusArea.textContent = 'Success! Message posted to candidate talk page.'; statusArea.style.color = 'green';
const stepElement = postButton.closest(config.selectors.stepElement);
const stepIndex = parseInt(stepElement?.dataset.step, 10);
if (stepElement && !isNaN(stepIndex) && steps[stepIndex]) {
steps[stepIndex].completed = true; stepElement.dataset.completed = true;
const checkbox = stepElement.querySelector(config.selectors.stepCheckbox); if (checkbox) checkbox.checked = true;
}
postButton.textContent = 'Posted'; // Keep disabled on success
} else {
statusArea.textContent = `Error posting message: ${result.error}. Please post manually.`; statusArea.style.color = 'red';
postButton.disabled = false; // Re-enable on error
}
}
/** Handles clicks on the "Grant Rights" button (Step 8). */
async function handleGrantRightsClick(removeCheckboxesMap) {
const grantButton = document.getElementById('rfx-grant-rights-button'); // Use specific ID
const statusArea = document.getElementById('rfx-grant-rights-status'); // Use specific ID
if (!grantButton || !statusArea) return;
grantButton.disabled = true; statusArea.textContent = 'Processing...'; statusArea.style.color = 'inherit';
const groupToAdd = config.rfxType === 'adminship' ? 'sysop' : 'bureaucrat';
const promotionReason = `Per [[${config.pageName}|successful RfX]]${config.tagLine}`;
const groupsToRemoveList = Object.entries(removeCheckboxesMap)
.filter(([groupName, checkboxWidget]) => checkboxWidget.isSelected())
.map(([groupName]) => groupName);
const groupsToRemoveString = groupsToRemoveList.length > 0 ? groupsToRemoveList.join('|') : null;
console.log("RfX Closer: Attempting grant.", { add: groupToAdd, remove: groupsToRemoveString });
try {
const currentGroups = await getUserGroups(actualCandidateUsername); // Re-check just before granting
const alreadyHasGroup = currentGroups && currentGroups.includes(groupToAdd);
if (alreadyHasGroup && !groupsToRemoveString) {
statusArea.textContent = `User already has '${groupToAdd}'. No action taken.`; statusArea.style.color = 'blue'; grantButton.disabled = false; return;
}
const data = await grantPermissionAPI(actualCandidateUsername, alreadyHasGroup ? null : groupToAdd, promotionReason, groupsToRemoveString);
console.log('Userrights API Success Response:', data); statusArea.textContent = 'API call successful. Verifying...';
const finalGroups = await getUserGroups(actualCandidateUsername); // Verify after grant
let finalMessage = "", finalColor = "orange", stepCompleted = false;
if (finalGroups && finalGroups.includes(groupToAdd)) {
finalMessage = `Success! User now has '${groupToAdd}'. `; finalColor = 'green'; stepCompleted = true;
} else if (finalGroups) {
if (alreadyHasGroup && groupsToRemoveString) { finalMessage = `User already had '${groupToAdd}'. `; finalColor = 'green'; stepCompleted = true; }
else { finalMessage = `API OK, but user does NOT have '${groupToAdd}'! Manual check required. `; finalColor = 'red'; }
} else { finalMessage = `API OK, but could not verify final groups. Manual check required. `; finalColor = 'orange'; }
const actuallyRemoved = data.userrights?.removed?.map(g => g.group) || [];
if (groupsToRemoveString) {
finalMessage += (actuallyRemoved.length > 0) ? `Removed: ${actuallyRemoved.join(', ')}.` : ` (No selected groups were removed).`;
}
statusArea.textContent = finalMessage; statusArea.style.color = finalColor;
if (stepCompleted) {
const stepElement = grantButton.closest(config.selectors.stepElement);
const stepIndex = parseInt(stepElement?.dataset.step, 10);
if (stepElement && !isNaN(stepIndex) && steps[stepIndex]) {
steps[stepIndex].completed = true; stepElement.dataset.completed = true;
const checkbox = stepElement.querySelector(config.selectors.stepCheckbox); if (checkbox) checkbox.checked = true;
}
}
} catch (error) {
console.error('Userrights API Error:', error);
statusArea.textContent = `Error: ${error.info || 'Unknown API error'}. Please grant manually.`; statusArea.style.color = 'red';
} finally {
grantButton.disabled = false; // Always re-enable button unless specific success case handled above
}
}
/** Handles clicks on the "Notify Candidate (Closing)" button (Step 10). */
async function handleNotifyCandidateClosingClick(messageGetter) {
const postButton = document.getElementById('rfx-notify-candidate-closing-button'); // Use specific ID
const statusArea = document.getElementById('rfx-notify-status-closing'); // Use specific ID
if (!postButton || !statusArea || !messageGetter) return;
postButton.disabled = true;
statusArea.textContent = 'Posting message...'; statusArea.style.color = 'inherit';
const messageContent = messageGetter(); // Get current message from the appropriate source
const candidateTalkPage = `User talk:${actualCandidateUsername}`;
const editSummary = `Notifying candidate of ${config.rfxType === 'adminship' ? 'RfA' : 'RfB'} outcome${config.tagLine}`;
const sectionTitle = `Outcome of your ${config.rfxType === 'adminship' ? 'RfA' : 'RfB'}`;
if (!messageContent) {
statusArea.textContent = 'Error: Message cannot be empty.'; statusArea.style.color = 'red';
postButton.disabled = false; return;
}
const result = await postToTalkPage(candidateTalkPage, sectionTitle, messageContent, editSummary);
if (result.success) {
statusArea.textContent = 'Success! Message posted to talk page.'; statusArea.style.color = 'green';
const stepElement = postButton.closest(config.selectors.stepElement);
const stepIndex = parseInt(stepElement?.dataset.step, 10);
if (stepElement && !isNaN(stepIndex) && steps[stepIndex]) {
steps[stepIndex].completed = true; stepElement.dataset.completed = true;
const checkbox = stepElement.querySelector(config.selectors.stepCheckbox); if (checkbox) checkbox.checked = true;
}
postButton.textContent = 'Posted'; // Keep disabled on success
} else {
statusArea.textContent = `Error posting message: ${result.error}. Please post manually.`; statusArea.style.color = 'red';
postButton.disabled = false; // Re-enable on error
}
}
/** Handles change in the outcome selector dropdown (Step 3). */
function handleOutcomeChange(event) {
const selectedOutcome = event.target.value;
const currentVotes = getCurrentVoteCounts();
// Mark step 3 as completed if an outcome is selected
if (steps[3]) steps[3].completed = selectedOutcome !== '';
const isClosingOutcome = selectedOutcome && !['', 'onhold'].includes(selectedOutcome);
const promises = [fetchRfaData()]; // Always ensure summary data
// Always fetch RfX page itself if an outcome is selected (for Step 4)
if (selectedOutcome) promises.push(fetchRfXWikitext());
// Pre-fetch pages needed for closing lists (Step 9)
if (isClosingOutcome) {
promises.push(fetchPageWikitext(config.baseRfxPage));
const year = new Date().getFullYear(); // Use current year
let yearlyListPageName = '', alphabeticalListPageName = '';
if (selectedOutcome === 'successful') {
yearlyListPageName = `Wikipedia:Successful ${config.rfxType} candidacies/${year}`;
} else { // Unsuccessful outcomes
yearlyListPageName = `Wikipedia:Unsuccessful ${config.rfxType} candidacies (Chronological)/${year}`;
const firstLetter = actualCandidateUsername.charAt(0).toUpperCase();
let alphaBase = `Wikipedia:Unsuccessful ${config.rfxType} candidacies`;
if (config.rfxType === 'adminship') { alphabeticalListPageName = (firstLetter >= 'A' && firstLetter <= 'Z') ? `${alphaBase}/${firstLetter}` : `${alphaBase} (Alphabetical)`; }
else { alphabeticalListPageName = `${alphaBase} (Alphabetical)`; }
}
if (yearlyListPageName) promises.push(fetchPageWikitext(yearlyListPageName));
if (alphabeticalListPageName) promises.push(fetchPageWikitext(alphabeticalListPageName));
}
// Wait for fetches before re-rendering
Promise.all(promises).then(() => {
renderAllSteps(selectedOutcome, currentVotes);
}).catch(error => {
console.error("RfX Closer: Error during pre-fetch in handleOutcomeChange:", error);
renderAllSteps(selectedOutcome, currentVotes); // Still try to render
});
}
/** Handles dragging the header. */
function handleHeaderMouseDown(e) {
if (e.target.tagName === 'BUTTON') return; // Ignore clicks on buttons
const containerEl = document.querySelector(config.selectors.container);
if (!containerEl) return;
isDragging = true;
dragStartX = e.clientX;
dragStartY = e.clientY;
const rect = containerEl.getBoundingClientRect();
containerStartX = rect.left;
containerStartY = rect.top;
containerEl.style.position = 'fixed';
containerEl.style.left = containerStartX + 'px';
containerEl.style.top = containerStartY + 'px';
containerEl.style.transform = ''; // Remove centering transform
containerEl.style.cursor = 'grabbing';
containerEl.style.userSelect = 'none';
document.addEventListener('mousemove', handleDocumentMouseMove);
document.addEventListener('mouseup', handleDocumentMouseUp);
}
function handleDocumentMouseMove(e) {
if (!isDragging) return;
const containerEl = document.querySelector(config.selectors.container);
if (!containerEl) return;
e.preventDefault();
const dx = e.clientX - dragStartX;
const dy = e.clientY - dragStartY;
containerEl.style.left = (containerStartX + dx) + 'px';
containerEl.style.top = (containerStartY + dy) + 'px';
}
function handleDocumentMouseUp() {
if (isDragging) {
const containerEl = document.querySelector(config.selectors.container);
if (containerEl) {
containerEl.style.cursor = 'grab';
containerEl.style.userSelect = '';
}
isDragging = false;
document.removeEventListener('mousemove', handleDocumentMouseMove);
document.removeEventListener('mouseup', handleDocumentMouseUp);
}
}
/** Handles the collapse/expand button click. */
function handleCollapseClick() {
const containerEl = document.querySelector(config.selectors.container);
const contentAndInputContainerEl = document.querySelector(config.selectors.contentAndInputContainer);
const collapseButtonEl = document.querySelector(config.selectors.collapseButton);
if (!containerEl || !contentAndInputContainerEl || !collapseButtonEl) return;
isCollapsed = !isCollapsed;
if (isCollapsed) {
contentAndInputContainerEl.style.display = 'none';
collapseButtonEl.innerHTML = '+';
containerEl.style.maxHeight = 'unset';
containerEl.style.height = 'auto';
} else {
contentAndInputContainerEl.style.display = 'flex';
collapseButtonEl.innerHTML = '−';
containerEl.style.maxHeight = '90vh';
containerEl.style.height = '';
// Force a reflow to ensure proper display
containerEl.style.display = 'flex';
containerEl.style.flexDirection = 'column';
// Re-render content
updateInputFields();
renderAllSteps(document.querySelector(config.selectors.outcomeSelector)?.value || '', getCurrentVoteCounts());
}
}
/** Handles the close button click. */
function handleCloseClick() {
const containerEl = document.querySelector(config.selectors.container);
if (containerEl) containerEl.style.display = 'none';
}
/** Handles the main launch button click. */
function handleLaunchClick(e) {
e.preventDefault();
const containerEl = document.querySelector(config.selectors.container);
if (!containerEl) return;
if (containerEl.style.display === 'none' || containerEl.style.display === '') {
fetchRfaData().then(() => {
updateInputFields();
renderAllSteps('', getCurrentVoteCounts());
containerEl.style.display = 'flex';
const supportInputEl = document.querySelector(config.selectors.supportInput);
const opposeInputEl = document.querySelector(config.selectors.opposeInput);
if (supportInputEl && !supportInputEl.dataset.listenerAttached) {
supportInputEl.addEventListener('input', updatePercentageDisplay);
supportInputEl.dataset.listenerAttached = 'true';
}
if (opposeInputEl && !opposeInputEl.dataset.listenerAttached) {
opposeInputEl.addEventListener('input', updatePercentageDisplay);
opposeInputEl.dataset.listenerAttached = 'true';
}
}).catch(err => {
console.error("RfX Closer: Failed to fetch initial data on launch.", err);
containerEl.style.display = 'flex';
const stepsContainerEl = document.querySelector(config.selectors.stepsContainer);
if (stepsContainerEl) stepsContainerEl.innerHTML = '<p style="color: red;">Error fetching initial data. Check console.</p>';
});
} else {
containerEl.style.display = 'none';
}
}
// --- UI Element Creation ---
function buildInitialUI() {
const container = createElement('div', { id: config.selectors.container.substring(1), style: { display: 'none' } });
// Header
const title = createElement('h2', { textContent: 'RfX Closer', className: config.selectors.title.substring(1) });
const collapseButton = createElement('button', { innerHTML: '−', title: 'Collapse/Expand', className: 'rfx-closer-button rfx-closer-collapse' });
const closeButton = createElement('button', { innerHTML: '×', title: 'Close', className: 'rfx-closer-button rfx-closer-close' });
const headerButtonContainer = createElement('div', {}, [collapseButton, closeButton]);
const header = createElement('div', { className: config.selectors.header.substring(1) }, [title, headerButtonContainer]);
// Input Section Helper
const createInputField = (label, id) => createElement('div', {}, [
createElement('label', { textContent: label, htmlFor: id }),
createElement('input', { type: 'number', id: id, min: '0' })
]);
// Input Section
const supportInputContainer = createInputField('Support', config.selectors.supportInput.substring(1));
const opposeInputContainer = createInputField('Oppose', config.selectors.opposeInput.substring(1));
const neutralInputContainer = createInputField('Neutral', config.selectors.neutralInput.substring(1));
const inputFields = createElement('div', { className: config.selectors.inputFields.substring(1) }, [supportInputContainer, opposeInputContainer, neutralInputContainer]);
const percentageDiv = createElement('div', { className: config.selectors.percentageDisplay.substring(1), textContent: 'Support percentage: N/A' });
const inputSection = createElement('div', { className: config.selectors.inputSection.substring(1) }, [inputFields, percentageDiv]);
// Content Section
const stepsContainer = createElement('div', { id: config.selectors.stepsContainer.substring(1) });
const contentContainer = createElement('div', { className: config.selectors.contentContainer.substring(1) }, [stepsContainer]);
// Assembly
const contentAndInputContainer = createElement('div', { className: config.selectors.contentAndInputContainer.substring(1) }, [inputSection, contentContainer]);
container.appendChild(header);
container.appendChild(contentAndInputContainer);
document.body.appendChild(container);
// Add Event Listeners to dynamically created elements
header.addEventListener('mousedown', handleHeaderMouseDown);
collapseButton.addEventListener('click', handleCollapseClick);
closeButton.addEventListener('click', handleCloseClick);
}
// --- CSS Styling ---
function addStyles() {
const styles = `
:root {
--border-color: #a2a9b1;
--border-color-light: #ccc;
--background-light: #f8f9fa;
--background-hover: #eaecf0;
--text-color: #202122;
--text-muted: #54595d;
--link-color: #0645ad;
--button-primary: #36c;
--button-hover: #447ff5;
--success-bg: #e6f3e6;
--success-border: #c3e6cb;
--error-bg: #f8d7da;
--error-border: #f5c6cb;
--warning-bg: #fcf8e3;
--warning-border: #faebcc;
--warning-text: #8a6d3b;
}
/* Base Container */
#rfx-closer-container {
position: fixed;
right: 10px;
top: 10px;
width: 380px;
max-height: 90vh;
background: var(--background-light);
border: 1px solid var(--border-color);
border-radius: 5px;
z-index: 1001;
box-shadow: 0 4px 8px rgba(0,0,0,0.15);
font-family: sans-serif;
font-size: 14px;
color: var(--text-color);
display: flex;
flex-direction: column;
padding: 10px;
overflow: hidden;
}
/* Content Container */
.rfx-closer-main-content {
display: flex;
flex-direction: column;
flex: 1;
overflow: auto;
min-height: 0;
}
/* Content and Input Container */
.rfx-closer-content-container {
flex: 1;
overflow-y: auto;
padding: 20px;
min-height: 100px;
}
/* Header Styles */
.rfx-closer-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 15px 20px;
background: var(--background-light);
border-bottom: 1px solid var(--border-color);
cursor: grab;
flex-shrink: 0;
}
.rfx-closer-title {
margin: 0;
font-size: 1.15em;
font-weight: bold;
}
/* Button Styles */
.rfx-closer-button,
.rfx-closer-action-button,
.rfx-notify-editlink {
border-radius: 3px;
cursor: pointer;
}
.rfx-closer-button {
background: none;
border: 1px solid transparent;
font-size: 1.5em;
color: var(--text-muted);
padding: 0 5px;
line-height: 1;
}
.rfx-closer-button:hover {
background-color: var(--background-hover);
}
.rfx-closer-action-button {
padding: 5px 10px;
background-color: var(--button-primary);
color: white;
border: 1px solid var(--button-primary);
margin: 10px 0;
font-size: 0.95em;
}
.rfx-closer-action-button:hover {
background-color: var(--button-hover);
}
.rfx-closer-action-button:disabled {
background-color: var(--border-color);
border-color: var(--border-color);
cursor: not-allowed;
}
/* Input Styles */
.rfx-closer-input-section {
padding: 15px;
border-bottom: 1px solid var(--border-color);
background: white;
flex-shrink: 0;
margin-bottom: 15px;
}
.rfx-closer-input-fields {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 10px;
}
.rfx-closer-input-fields div {
display: flex;
flex-direction: column;
gap: 4px;
}
.rfx-closer-input-fields label {
font-size: 0.85em;
color: var(--text-muted);
}
/* Common Input Elements */
.rfx-closer-input-fields input,
.rfx-notify-textarea,
.rfx-crat-chat-textarea,
.rfx-onhold-notify-textarea {
padding: 5px;
border: 1px solid var(--border-color);
border-radius: 3px;
width: 100%;
box-sizing: border-box;
margin: 5px 0;
}
/* Status Elements */
.rfx-closer-api-status,
.rfx-notify-status,
.rfx-crat-notify-status,
.rfx-candidate-onhold-notify-status {
font-size: 0.9em;
margin-top: 8px;
}
/* Info Boxes */
.rfx-closer-info-box,
.rfx-closer-known-issue,
.rfx-closer-percentage {
margin-top: 10px;
padding: 8px 10px;
border-radius: 3px;
font-size: 0.9em;
}
.rfx-closer-info-box {
border: 1px solid var(--border-color-light);
background-color: var(--background-light);
}
.rfx-closer-info-box.error {
background-color: var(--error-bg);
border-color: var(--error-border);
color: #721c24;
}
.rfx-closer-known-issue {
border: 1px solid var(--warning-border);
background-color: var(--warning-bg);
color: var(--warning-text);
}
/* Launch Button */
#rfx-closer-launch {
color: var(--link-color) !important;
text-decoration: none;
cursor: pointer;
}
#rfx-closer-launch:hover {
text-decoration: underline !important;
}
/* Step Styles */
.rfx-closer-step {
margin-bottom: 20px;
padding: 15px;
border: 1px solid var(--border-color);
border-radius: 4px;
background: white;
transition: background-color 0.3s ease;
overflow-x: auto; /* Allow horizontal scrolling if needed */
}
.rfx-closer-step[data-completed="true"] {
background-color: var(--success-bg);
border-color: var(--success-border);
}
/* Status Colors */
.rfx-crat-notify-status .success { color: green; }
.rfx-crat-notify-status .error { color: red; }
.rfx-crat-notify-status .warning { color: orange; }
/* OOUI Overrides */
.rfx-closer-checkbox.oo-ui-widget-disabled,
.rfx-closer-checkbox.oo-ui-widget-disabled .oo-ui-checkboxInputWidget-checkIcon {
opacity: 1 !important;
}
/* Link Styles */
.rfx-action-link {
display: inline-block;
margin: 5px 0;
padding: 5px 10px;
background-color: var(--background-light);
border: 1px solid var(--border-color);
border-radius: 3px;
text-decoration: none;
color: var(--link-color);
}
.rfx-action-link:hover {
background-color: var(--background-hover);
}
/* Dropdown Styles */
#rfx-outcome-selector {
width: 100%;
padding: 5px;
margin-top: 10px;
border: 1px solid var(--border-color);
border-radius: 3px;
background-color: white;
}
`;
document.head.appendChild(createElement('style', { textContent: styles }));
}
// --- Initialization ---
mw.loader.using(['oojs-ui-core', 'oojs-ui-widgets', 'mediawiki.api', 'mediawiki.widgets.DateInputWidget', 'mediawiki.util'], function() {
buildInitialUI(); // Create the UI elements
addStyles(); // Add the CSS
// Create and add launch button
const launchButton = createElement('a', {
id: config.selectors.launchButton.substring(1),
textContent: 'RfX Closer',
href: '#'
});
launchButton.addEventListener('click', handleLaunchClick); // Attach listener
const pageTools = document.querySelector(config.selectors.toolsMenu);
if (pageTools) {
const li = createElement('li', { id: config.selectors.launchListItem.substring(1) }, [launchButton]);
pageTools.appendChild(li);
}
});
console.log("RfX Closer: Script loaded (Refactored).");
})();