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 - A tool for closing Requests for Adminship/Bureaucratship on Wikipedia
*
* CHANGE LOG:
* - Added functionality to generate wikitext for inserting unsuccessful candidates into the correct alphabetical list page (e.g., /A, /B, etc.).
* - Made support percentage display update dynamically when support/oppose counts are manually changed.
* - Added step 7: Notify Candidate on their talk page with templates/custom messages.
* - Added API button to automatically post the notification message in Step 7.
* - Replaced most wikitext display boxes with "Copy Code" and "Edit Page" links.
* - Updated finaltally reason for withdrawn to 'WD'.
* - Clarified text in Step 6. Restored action links for yearly chrono list. Restored intro text for list updates section.
* - Updated help text for custom message copy button in Step 7.
* - Added warning about known issue with uncommenting month headers in Step 6.
* - Fixed bug where incorrect outcome code (with wikilinks) was used in rfarow template for NOTNOW/SNOW.
* - Updated rfarow template generation to use full outcome words (e.g., "unsuccessful") instead of codes (e.g., "f").
* - Restored "On hold" outcome option.
* - Updated Step 4 logic for "On hold" to include finaltally and chat link.
* - Added new Step 5 to guide creation of Bureaucrat Chat page for "On hold" outcome.
* - Added new Step 6 (for 'onhold' only): Notify Bureaucrats via API about the crat chat.
* - Added new Step 7 (for 'onhold' only): Notify Candidate via API about the hold.
* - Renumbered subsequent steps (Promotion, Update Lists, Notify Candidate are now 8, 9, 10).
* - Adjusted visibility logic for steps based on 'onhold' outcome.
* - Fixed typo in fetchBureaucrats API call (gmgrou -> gmgroup).
* - Improved error checking and logging in fetchBureaucrats.
*/
(function() {
'use strict';
// --- Configuration & Initialization ---
// Check if the current page is a main RfA or RfB subpage
const pageName = mw.config.get('wgPageName');
const rfxType = pageName.includes('Requests_for_adminship') ? 'adminship' : 'bureaucratship';
// Use underscores for internal API calls and links where MediaWiki handles normalization
const baseRfxPage = `Wikipedia:Requests_for_${rfxType}`;
// Use spaces for display text
const displayBaseRfxPage = baseRfxPage.replace(/_/g, ' ');
if (!new RegExp(`^${baseRfxPage.replace(/ /g, '_')}/[^/]+$`).test(pageName)) { // Ensure regex uses underscores
// Don't run on talk pages, sub-subpages (/Support, etc.), or the main RfA list page
console.log('RfX Closer: Not on a main RfX subpage. Exiting.');
return;
}
const candidateSubpage = pageName.split('/').pop(); // Get the candidate part of the pagename
const tagLine = ' (using [[User:Barkeep49/rfxCloser.js|rfxCloser]])'; // Adjusted tagline
const closerUsername = mw.config.get('wgUserName') || 'YourUsername'; // Get current user or fallback
// Store fetched data to avoid multiple API calls
let rfaData = null;
let rfaWikitext = null; // Store fetched wikitext for *this* RfX page
let basePageWikitextCache = {}; // Cache for other pages like the base RfX list
let bureaucratListCache = null; // Cache for bureaucrat list
let actualCandidateUsername = candidateSubpage; // Default to subpage name
let fetchErrorOccurred = false; // Flag to track if the initial fetch failed
let wikitextErrorOccurred = false; // Flag for wikitext fetch error
// --- UI Element Creation ---
const container = document.createElement('div');
container.id = 'rfx-closer-container';
// Styles moved to CSS block below for better readability
const header = document.createElement('div');
header.className = 'rfx-closer-header';
const title = document.createElement('h2');
title.textContent = 'RfX Closer';
title.className = 'rfx-closer-title';
const headerButtonContainer = document.createElement('div');
const collapseButton = document.createElement('button');
collapseButton.innerHTML = '−'; // Use innerHTML for entities
collapseButton.title = 'Collapse/Expand';
collapseButton.className = 'rfx-closer-button rfx-closer-collapse';
const closeButton = document.createElement('button');
closeButton.innerHTML = '×'; // Use innerHTML for entities
closeButton.title = 'Close';
closeButton.className = 'rfx-closer-button rfx-closer-close';
header.appendChild(title);
headerButtonContainer.appendChild(collapseButton);
headerButtonContainer.appendChild(closeButton);
header.appendChild(headerButtonContainer);
const contentAndInputContainer = document.createElement('div');
contentAndInputContainer.className = 'rfx-closer-main-content';
const inputSection = document.createElement('div');
inputSection.className = 'rfx-closer-input-section';
const inputFields = document.createElement('div');
inputFields.className = 'rfx-closer-input-fields';
const createInputField = (label, id) => {
const inputContainer = document.createElement('div');
const labelElement = document.createElement('label');
labelElement.textContent = label;
labelElement.htmlFor = id;
const input = document.createElement('input');
input.type = 'number';
input.id = id;
input.min = '0';
inputContainer.appendChild(labelElement);
inputContainer.appendChild(input);
return inputContainer;
};
inputFields.appendChild(createInputField('Support', 'support-count'));
inputFields.appendChild(createInputField('Oppose', 'oppose-count'));
inputFields.appendChild(createInputField('Neutral', 'neutral-count'));
inputSection.appendChild(inputFields);
// --- Create Percentage Display Area ---
// Create it here so it exists before update functions are called
const percentageDiv = document.createElement('div');
percentageDiv.className = 'rfx-closer-percentage';
percentageDiv.textContent = 'Support percentage: N/A'; // Initial text
inputSection.appendChild(percentageDiv); // Add it to the input section
const contentContainer = document.createElement('div');
contentContainer.className = 'rfx-closer-content-container';
const stepsContainer = document.createElement('div');
stepsContainer.id = 'rfx-closer-steps';
// --- CSS Styling ---
const styles = `
#rfx-closer-container {
position: fixed;
right: 10px;
top: 50%;
transform: translateY(-50%);
width: 380px; /* Increased width slightly */
max-height: 90vh;
background: #f8f9fa;
border: 1px solid #a2a9b1;
border-radius: 5px; /* Slightly more rounded */
z-index: 1001; /* Ensure it's above most elements */
box-shadow: 0 4px 8px rgba(0,0,0,0.15); /* Stronger shadow */
display: none; /* Initially hidden */
font-family: sans-serif;
font-size: 14px; /* Base font size */
color: #202122;
display: flex; /* Use flex for layout */
flex-direction: column;
}
.rfx-closer-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 15px;
background: #f8f9fa;
border-bottom: 1px solid #a2a9b1;
cursor: grab;
flex-shrink: 0; /* Prevent header from shrinking */
}
.rfx-closer-title {
margin: 0;
font-size: 1.15em; /* Adjusted size */
font-weight: bold;
}
.rfx-closer-button {
background: none;
border: 1px solid transparent;
font-size: 1.5em;
cursor: pointer;
color: #54595d;
padding: 0 5px;
line-height: 1;
border-radius: 3px;
}
.rfx-closer-button:hover {
background-color: #eaecf0; /* Subtle hover */
}
.rfx-closer-button:focus {
outline: none;
border-color: #36c; /* Focus indicator */
box-shadow: 0 0 0 1px #36c;
}
.rfx-closer-action-button { /* Style for API button */
padding: 5px 10px;
background-color: #36c;
color: white;
border: 1px solid #36c;
border-radius: 3px;
cursor: pointer;
margin-top: 10px; /* Add space above button */
margin-right: 10px;
font-size: 0.95em;
}
.rfx-closer-action-button:hover {
background-color: #447ff5;
}
.rfx-closer-action-button:disabled {
background-color: #a2a9b1;
border-color: #a2a9b1;
cursor: not-allowed;
}
.rfx-closer-api-status { /* Generic API status */
font-size: 0.9em;
font-style: italic;
margin-top: 5px;
}
.rfx-closer-beta-warning { /* Style for beta warning */
font-size: 0.9em;
font-weight: bold;
color: #8a6d3b; /* Bootstrap 'warning' text color */
background-color: #fcf8e3; /* Bootstrap 'warning' background color */
border: 1px solid #faebcc; /* Bootstrap 'warning' border color */
padding: 8px;
border-radius: 3px;
margin-top: 5px;
margin-bottom: 10px;
}
.rfx-closer-beta-warning a {
color: #66512c; /* Darker link color for warning */
text-decoration: underline;
}
/* Styles for OOUI elements within the step */
.rfx-closer-step-description .oo-ui-fieldsetLayout {
margin: 10px 0;
border: 1px solid #c8ccd1;
padding: 10px;
border-radius: 2px;
}
.rfx-closer-step-description .oo-ui-fieldsetLayout-header {
font-weight: bold;
margin-bottom: 5px;
padding-bottom: 5px;
border-bottom: 1px solid #c8ccd1;
}
.rfx-closer-step-description .oo-ui-fieldLayout-align-inline > .oo-ui-fieldLayout-body > .oo-ui-fieldLayout-field {
display: inline-block; /* Make checkbox and label align better */
margin-right: 15px; /* Space between checkboxes */
margin-bottom: 5px; /* Space below checkboxes if they wrap */
}
.rfx-closer-step-description .oo-ui-checkboxInputWidget {
vertical-align: middle; /* Align checkboxes better with text */
}
.rfx-closer-collapse { margin-right: 5px; }
.rfx-closer-main-content {
overflow: hidden; /* Contains scrolling content */
display: flex;
flex-direction: column;
flex-grow: 1; /* Allow this section to grow */
}
.rfx-closer-input-section {
padding: 15px;
border-bottom: 1px solid #a2a9b1;
background: #fff;
flex-shrink: 0; /* Prevent input section from shrinking */
}
.rfx-closer-input-fields {
display: grid;
grid-template-columns: repeat(3, 1fr); /* Always 3 columns */
gap: 10px;
}
.rfx-closer-input-fields div {
display: flex;
flex-direction: column;
gap: 4px;
}
.rfx-closer-input-fields label {
font-size: 0.85em;
color: #54595d;
}
.rfx-closer-input-fields input {
padding: 6px 8px; /* Adjusted padding */
border: 1px solid #a2a9b1;
border-radius: 3px;
width: 100%;
box-sizing: border-box;
text-align: right;
font-size: 0.95em; /* Slightly larger input font */
}
.rfx-closer-content-container {
padding: 15px;
overflow-y: auto; /* Enable vertical scroll */
flex-grow: 1; /* Allow content to take remaining space */
overscroll-behavior: contain;
}
.rfx-closer-step {
margin-bottom: 15px;
padding: 12px; /* Increased padding */
border: 1px solid #a2a9b1;
border-radius: 4px;
background: #fff;
transition: background-color 0.3s ease; /* Smooth transition */
}
.rfx-closer-step[data-completed="true"] {
background-color: #e6f3e6; /* Greenish background for completed */
border-color: #c3e6cb;
}
.rfx-closer-step h3 {
margin: 0 0 8px 0; /* Adjusted margin */
font-size: 1.05em; /* Slightly larger step title */
font-weight: bold;
display: flex; /* Use flex for checkbox alignment */
align-items: center;
}
.rfx-closer-step h3 input[type="checkbox"] {
margin-right: 8px; /* Space between checkbox and title */
margin-top: 0; /* Reset margin */
vertical-align: middle; /* Align checkbox */
width: 16px; /* Explicit size */
height: 16px;
}
.rfx-closer-step-description {
margin: 0;
font-size: 0.95em; /* Slightly larger description text */
color: #333; /* Darker text */
line-height: 1.5; /* Improved line spacing */
}
.rfx-closer-step-description p { margin: 0 0 10px 0; } /* Spacing for paragraphs */
.rfx-closer-step-description p:last-child { margin-bottom: 0; }
.rfx-closer-step-description code {
background-color: #f0f0f0;
padding: 0.1em 0.4em;
border-radius: 3px;
font-size: 0.9em;
}
.rfx-closer-step-description a { color: #36c; text-decoration: none; }
.rfx-closer-step-description a:hover { text-decoration: underline; }
.rfx-closer-step-description ul, .rfx-closer-step-description ol { padding-left: 20px; margin: 10px 0; }
.rfx-closer-step-description li { margin-bottom: 5px; }
.rfx-closer-info-box {
margin-top: 10px;
padding: 8px 10px;
border: 1px solid #ccc;
border-radius: 3px;
font-size: 0.9em;
background-color: #f8f9fa;
}
/* Style for info box when data fetch fails */
.rfx-closer-info-box.error {
background-color: #f8d7da;
border-color: #f5c6cb;
color: #721c24;
}
/* Style for known issue warning */
.rfx-closer-known-issue {
margin-top: 8px;
padding: 8px 10px;
border: 1px solid #faebcc; /* Warning border */
border-radius: 3px;
font-size: 0.9em;
background-color: #fcf8e3; /* Warning background */
color: #8a6d3b; /* Warning text */
}
#rfx-outcome-selector {
width: 100%;
margin: 10px 0 5px 0; /* Adjusted margin */
padding: 8px;
border: 1px solid #a2a9b1;
border-radius: 4px;
background-color: #fff;
font-size: 0.95em;
cursor: pointer;
}
/* Copy Box Styles (kept for specific fallback cases) */
.rfx-copy-box-container {
position: relative;
margin-top: 8px;
margin-bottom: 12px;
}
.rfx-copy-box-display {
background-color: #f0f0f0; /* Lighter grey */
border: 1px solid #ccc; /* Lighter border */
padding: 10px;
padding-right: 60px; /* Space for button */
font-family: monospace;
font-size: 0.9em;
margin: 0;
border-radius: 3px;
white-space: pre-wrap;
word-break: break-all;
overflow-x: auto;
color: #333;
max-height: 200px; /* Limit height for full wikitext */
overflow-y: auto; /* Add scroll for full wikitext */
}
.rfx-copy-box-button {
position: absolute;
top: 5px;
right: 5px;
background-color: #eaf3ff;
border: 1px solid #a2a9b1;
color: #333;
padding: 3px 8px;
border-radius: 3px;
cursor: pointer;
font-size: 0.8em;
transition: background-color 0.2s ease;
z-index: 1; /* Ensure button is above text */
}
.rfx-copy-box-button:hover { background-color: #cfe2ff; }
.rfx-copy-box-button:disabled {
cursor: default;
opacity: 0.7;
}
.rfx-copy-box-button.copied { background-color: #d1e7dd !important; }
.rfx-copy-box-button.error { background-color: #f8d7da !important; }
.rfx-copy-box-helptext {
font-size: 0.85em;
color: #54595d;
margin-top: 4px;
}
/* Launch Button */
#rfx-closer-launch-li { /* Style the list item */
border-left: 1px solid #a2a9b1;
margin-left: 0.5em;
padding-left: 0.5em;
}
#rfx-closer-launch {
display: inline-block;
padding: 0.3em 0.8em; /* Adjusted padding */
background: #36c;
color: white !important; /* Ensure white text */
text-decoration: none !important;
border-radius: 3px;
font-size: 0.9em;
cursor: pointer;
transition: background-color 0.2s ease;
}
#rfx-closer-launch:hover {
background: #447ff5; /* Lighter blue on hover */
color: white !important;
}
.rfx-closer-percentage {
margin-top: 10px;
padding: 8px;
background-color: #f8f9fa;
border: 1px solid #a2a9b1;
border-radius: 3px;
font-size: 0.95em;
}
/* Add CSS for disabled checkbox styling */
.rfx-closer-checkbox.oo-ui-widget-disabled {
opacity: 1 !important;
}
.rfx-closer-checkbox.oo-ui-widget-disabled .oo-ui-checkboxInputWidget-checkIcon {
opacity: 1 !important;
}
/* Styles for Notification Step (Step 10) */
.rfx-notify-options label {
display: block;
margin-bottom: 5px;
}
.rfx-notify-options input[type="radio"] {
margin-right: 5px;
}
.rfx-notify-textarea { /* Used in Step 10 */
width: 100%;
min-height: 100px;
margin-top: 5px;
margin-bottom: 5px;
padding: 5px;
border: 1px solid #a2a9b1;
border-radius: 3px;
font-family: monospace;
box-sizing: border-box; /* Include padding and border in width */
}
/* Styles for Action Links */
.rfx-action-links-container {
margin-top: 10px;
margin-bottom: 10px;
}
.rfx-action-link {
display: inline-block;
margin-right: 15px; /* Space between links */
padding: 5px 0px; /* Minimal padding */
text-decoration: none;
font-size: 0.9em;
cursor: pointer;
}
.rfx-action-link:hover {
text-decoration: underline;
}
.rfx-action-link.copied {
color: green;
font-weight: bold;
}
.rfx-action-link.error {
color: red;
font-weight: bold;
}
.rfx-notify-editlink { /* Keep this for the manual fallback in notify step */
display: inline-block;
margin-top: 10px;
margin-left: 10px; /* Space between buttons */
padding: 5px 10px;
background-color: #f8f9fa;
border: 1px solid #a2a9b1;
border-radius: 3px;
text-decoration: none;
font-size: 0.9em;
}
.rfx-notify-editlink:hover {
background-color: #eaecf0;
}
.rfx-notify-status { /* Style for API status message in Step 10 */
font-size: 0.9em;
margin-top: 8px;
}
/* Styles for Crat Chat Step (Step 5) */
.rfx-crat-chat-textarea {
width: 100%;
min-height: 80px;
margin-top: 5px;
margin-bottom: 5px;
padding: 5px;
border: 1px solid #a2a9b1;
border-radius: 3px;
font-family: sans-serif; /* Use standard font */
box-sizing: border-box;
}
/* Styles for NEW Steps 6 & 7 */
.rfx-onhold-notify-textarea {
width: 100%;
min-height: 80px; /* Slightly smaller */
margin-top: 5px;
margin-bottom: 5px;
padding: 5px;
border: 1px solid #a2a9b1;
border-radius: 3px;
font-family: sans-serif; /* Use standard font */
box-sizing: border-box;
}
.rfx-crat-notify-status, .rfx-candidate-onhold-notify-status {
font-size: 0.9em;
margin-top: 8px;
max-height: 60px; /* Limit height for multi-line status */
overflow-y: auto; /* Allow scrolling */
}
.rfx-crat-notify-status ul {
margin: 0;
padding-left: 15px;
font-size: 0.9em;
}
.rfx-crat-notify-status li {
margin-bottom: 2px;
}
.rfx-crat-notify-status .success { color: green; }
.rfx-crat-notify-status .error { color: red; }
.rfx-crat-notify-status .warning { color: orange; }
`;
const styleSheet = document.createElement("style");
styleSheet.textContent = styles;
document.head.appendChild(styleSheet);
// --- Assemble UI ---
document.body.appendChild(container);
container.appendChild(header);
contentAndInputContainer.appendChild(inputSection); // Input section added here
contentAndInputContainer.appendChild(contentContainer);
container.appendChild(contentAndInputContainer);
contentContainer.appendChild(stepsContainer);
// --- Data Fetching ---
// Fetches summary data from the main RfX list page
const fetchRfaData = () => {
// Return cached data if available and no error occurred previously
if (rfaData && !fetchErrorOccurred) return Promise.resolve(rfaData);
const api = new mw.Api();
console.log(`RfX Closer: Fetching summary data for RfX: ${candidateSubpage} from ${baseRfxPage}`);
fetchErrorOccurred = false; // Reset error flag on new fetch attempt
return api.get({
action: 'parse',
page: baseRfxPage, // Fetch the main list page
prop: 'text',
formatversion: 2,
format: 'json'
}).then(data => {
const htmlContent = data.parse.text;
const tempDiv = document.createElement('div');
tempDiv.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');
// Normalize href for comparison (replace spaces with underscores)
const linkHref = rfaLink ? rfaLink.getAttribute('href').replace(/ /g, '_') : '';
const targetHref = `/wiki/${baseRfxPage.replace(/ /g, '_')}/${candidateSubpage}`;
if (rfaLink && linkHref === targetHref) {
const cells = row.querySelectorAll('td');
if (cells.length >= 8) {
foundData = {
candidate: rfaLink.textContent.trim(),
support: cells[1] ? cells[1].textContent.trim() : '0',
oppose: cells[2] ? cells[2].textContent.trim() : '0',
neutral: cells[3] ? cells[3].textContent.trim() : '0',
percent: cells[4] ? cells[4].textContent.trim().replace('%', '') : 'N/A',
status: cells[5] ? cells[5].textContent.trim() : 'N/A',
endTime: cells[6] ? cells[6].textContent.trim() : 'N/A',
timeLeft: cells[7] ? cells[7].textContent.trim() : 'N/A',
};
console.log('RfX Closer: Found summary data:', foundData);
}
}
});
}
if (foundData) {
rfaData = foundData;
actualCandidateUsername = rfaData.candidate; // Update username from fetched data
} else {
console.warn(`RfX Closer: Could not find summary data for ${candidateSubpage}. 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;
}).catch(error => {
console.error('RfX Closer: Error fetching summary data:', error);
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
async function fetchPageWikitext(targetPageName) {
// Normalize page name (use underscores for API)
const normalizedPageName = targetPageName.replace(/ /g, '_');
// Return cached wikitext if available
if (basePageWikitextCache[normalizedPageName]) {
console.log(`RfX Closer: Using cached wikitext for ${normalizedPageName}.`);
return basePageWikitextCache[normalizedPageName];
}
const api = new mw.Api();
console.log(`RfX Closer: Fetching wikitext for page: ${normalizedPageName}`);
try {
const data = await api.get({
action: 'query',
prop: 'revisions',
titles: normalizedPageName, // Use normalized name
rvslots: 'main',
rvprop: 'content',
formatversion: 2,
format: 'json'
});
const page = data.query.pages[0];
if (page && !page.missing && page.revisions && page.revisions[0].slots.main.content) {
const wikitext = page.revisions[0].slots.main.content;
basePageWikitextCache[normalizedPageName] = wikitext; // Cache the result
console.log(`RfX Closer: Successfully fetched wikitext for ${normalizedPageName}.`);
return wikitext;
} else {
// If page is missing (e.g., yearly chrono page doesn't exist yet), return empty string
if (page && page.missing) {
console.log(`RfX Closer: Page ${normalizedPageName} does not exist. Returning empty wikitext.`);
basePageWikitextCache[normalizedPageName] = ''; // Cache empty result
return '';
}
console.error(`RfX Closer: Could not find wikitext for page ${normalizedPageName}. Response:`, data);
return null; // Indicate other error
}
} catch (error) {
console.error(`RfX Closer: Error fetching wikitext for ${normalizedPageName}:`, error);
throw error; // Re-throw for the caller to handle
}
}
// Specific function for the current RfX page wikitext (uses generic fetcher)
const fetchRfXWikitext = () => {
// Use the generic fetcher, relies on its internal caching
return fetchPageWikitext(pageName).catch(err => {
// Set specific flag for RfX page fetch error
wikitextErrorOccurred = true;
return null;
});
};
// Fetches the list of bureaucrats
async function fetchBureaucrats() {
if (bureaucratListCache) {
console.log("RfX Closer: Using cached bureaucrat list.");
return bureaucratListCache;
}
const api = new mw.Api();
console.log("RfX Closer: Fetching bureaucrat list...");
try {
const data = await api.get({
action: 'query',
list: 'groupmembers',
gmgroup: 'bureaucrat',
gmlimit: 'max',
format: 'json',
formatversion: 2
});
// More detailed check and logging
if (data && data.query && Array.isArray(data.query.groupmembers)) { // Check if it's an array
if (data.query.groupmembers.length > 0) {
bureaucratListCache = data.query.groupmembers.map(member => member.name);
console.log(`RfX Closer: Found ${bureaucratListCache.length} bureaucrats.`);
return bureaucratListCache;
} else {
// Array exists but is empty
console.warn("RfX Closer: API returned an empty list for the 'bureaucrat' group. Response:", data);
bureaucratListCache = []; // Cache the empty result
return [];
}
} else {
// Query or groupmembers property is missing or not an array
console.error("RfX Closer: Could not parse bureaucrat list from API response (query or groupmembers missing/invalid). Full response:", JSON.stringify(data)); // Log full response string
return [];
}
} catch (error) {
console.error("RfX Closer: Error fetching bureaucrat list:", error);
return []; // Return empty list on error
}
}
// --- Function to update the percentage display ---
const updatePercentageDisplay = () => {
const supportInput = document.getElementById('support-count');
const opposeInput = document.getElementById('oppose-count');
const percentageDiv = document.querySelector('.rfx-closer-percentage'); // Find the display div
if (!supportInput || !opposeInput || !percentageDiv) {
console.warn('RfX Closer: Could not find input fields or percentage display for dynamic update.');
return;
}
const support = parseInt(supportInput.value, 10) || 0; // Default to 0 if NaN
const oppose = parseInt(opposeInput.value, 10) || 0; // Default to 0 if NaN
const total = support + oppose;
const percentage = total > 0 ? (support / total * 100).toFixed(2) : 0;
percentageDiv.textContent = `Support percentage: ${percentage}% (${support}/${total})`;
};
const updateInputFields = () => {
// Use the promise directly to avoid race conditions if called before initial fetch completes
fetchRfaData().then(data => {
if (data) {
document.getElementById('support-count').value = data.support || '';
document.getElementById('oppose-count').value = data.oppose || '';
document.getElementById('neutral-count').value = data.neutral || '';
// Calculate and update percentage using the new function
updatePercentageDisplay(); // Call the dedicated function
} else {
document.getElementById('support-count').value = '';
document.getElementById('oppose-count').value = '';
document.getElementById('neutral-count').value = '';
updatePercentageDisplay(); // Update percentage even if fetch fails (will show 0%)
}
});
};
// --- Helper Functions ---
// Helper to escape characters for use in regex
function escapeRegex(string) {
// Escape basic regex characters. Handles most common cases in usernames/pagenames.
// Also replace spaces with underscores OR spaces to match either in regex
const spacedString = string.replace(/_/g, ' ');
const underscoredString = string.replace(/ /g, '_');
// If they are the same, just escape one
if (spacedString === underscoredString) {
return spacedString.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
// If different, create a pattern like (Underscore_Version|Space Version)
const escapedSpaced = spacedString.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const escapedUnderscored = underscoredString.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
return `(?:${escapedUnderscored}|${escapedSpaced})`;
}
// API function to check user groups
async function getUserGroups(username) {
const api = new mw.Api();
console.log(`getUserGroups: Fetching groups for ${username}`);
try {
const data = await api.get({
action: 'query', list: 'users', ususers: username, usprop: 'groups',
format: 'json', formatversion: 2
});
if (data.query?.users?.[0]) {
const user = data.query.users[0];
if (user.missing || user.invalid) {
console.warn(`getUserGroups: User '${username}' not found or invalid.`);
return null; // Indicate user not found
}
const groups = user.groups || [];
console.log(`getUserGroups: Found groups for ${username}:`, groups);
return groups;
} else {
console.warn(`getUserGroups: Could not find user data for '${username}'.`, data);
return null; // Indicate API error or unexpected response
}
} catch (error) {
console.error(`getUserGroups: API error checking groups for '${username}'.`, error);
throw error; // Rethrow so the caller knows it failed
}
}
// API function to grant/remove rights
function grantPermissionAPI(username, groupToAdd, reason, groupsToRemove = null, expiry = 'infinity') {
const api = new mw.Api();
const params = {
action: 'userrights',
format: 'json',
user: username,
reason: reason,
expiry: expiry
};
// Add 'add' parameter only if groupToAdd is provided
if (groupToAdd) {
params.add = groupToAdd;
}
// Add the 'remove' parameter only if groupsToRemove is provided and not empty
if (groupsToRemove) {
params.remove = groupsToRemove; // Expects pipe-separated string
}
// Use postWithToken to handle edit tokens automatically
return api.postWithToken('userrights', params); // Returns a jQuery promise
}
// Helper function to post a message to a talk page
async function postToTalkPage(targetPage, sectionTitle, messageContent, summary) {
const api = new mw.Api();
try {
await api.postWithToken('edit', {
action: 'edit',
title: targetPage,
section: 'new',
sectiontitle: sectionTitle,
text: messageContent,
summary: summary,
format: 'json'
});
return { success: true, page: targetPage };
} catch (error) {
console.error(`RfX Closer: Error posting to ${targetPage}:`, error);
const errorCode = error?.error?.code || 'unknown';
const errorInfo = error?.error?.info || 'Unknown error';
return { success: false, page: targetPage, error: `${errorInfo} (${errorCode})` };
}
}
// Helper to add a delay
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
// --- Helper Function to create Copy Code and Edit Page links ---
/**
* Creates a container with "Copy Code" and "Edit Page" links.
* @param {string} targetPage - The full page name (e.g., "Wikipedia:Some_Page"). Can be null if only copy is needed.
* @param {string|Function} wikitextOrGetter - The wikitext string or a function that returns it.
* @param {string} linkTextPrefix - Text to prepend to link labels (e.g., "Full RfX Page").
* @param {boolean} isCreateLink - If true, the edit link text says "Create Page". Defaults to false.
* @returns {HTMLElement} A div element containing the links.
*/
function createActionLinks(targetPage, wikitextOrGetter, linkTextPrefix = '', isCreateLink = false) {
const linksContainer = document.createElement('div');
linksContainer.className = 'rfx-action-links-container';
// --- Copy Link ---
const copyLink = document.createElement('a');
copyLink.href = '#';
copyLink.textContent = `Copy Code${linkTextPrefix ? ' for ' + linkTextPrefix : ''}`;
copyLink.className = 'rfx-action-link';
copyLink.title = 'Copy generated wikitext to clipboard';
copyLink.addEventListener('click', (e) => {
e.preventDefault();
// Get the current wikitext, either directly or via the getter function
const textToCopy = (typeof wikitextOrGetter === 'function') ? wikitextOrGetter() : wikitextOrGetter;
if (textToCopy === null || textToCopy === undefined) {
console.warn("RfX Closer: Attempted to copy null or 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'; // Make it stand out
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);
// --- Edit/Create Link ---
// Only add edit link if targetPage is provided
if (targetPage) {
const editLink = document.createElement('a');
// Use mw.util.getUrl to handle encoding and base path correctly
editLink.href = mw.util.getUrl(targetPage, { action: 'edit' });
editLink.target = '_blank'; // Open in new tab
const linkVerb = isCreateLink ? 'Create' : 'Edit';
editLink.textContent = `${linkVerb} ${linkTextPrefix || targetPage.replace(/_/g, ' ')}`;
editLink.className = 'rfx-action-link';
editLink.title = `${linkVerb} ${targetPage.replace(/_/g, ' ')} in edit mode`;
linksContainer.appendChild(editLink);
}
return linksContainer;
}
// Kept for specific fallback cases if needed (e.g., simple template copy)
function createCopyableBox(content, helpText = 'Click button to copy', isLarge = false, inputElement = null) {
const boxContainer = document.createElement('div');
boxContainer.className = 'rfx-copy-box-container';
const displayElement = inputElement ? inputElement : document.createElement('pre');
if (!inputElement) {
displayElement.textContent = content;
displayElement.className = 'rfx-copy-box-display';
if (isLarge) { displayElement.style.maxHeight = '300px'; }
boxContainer.appendChild(displayElement);
}
const copyButton = document.createElement('button');
copyButton.textContent = 'Copy';
copyButton.title = helpText;
copyButton.className = 'rfx-copy-box-button';
if (inputElement && inputElement.tagName === 'TEXTAREA') { copyButton.style.top = 'auto'; copyButton.style.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);
const helpTextElement = document.createElement('div');
helpTextElement.textContent = helpText;
helpTextElement.className = 'rfx-copy-box-helptext';
boxContainer.appendChild(helpTextElement);
return boxContainer;
}
// Helper to get current vote counts from input fields
function getCurrentVoteCounts() {
return {
support: document.getElementById('support-count').value || '0',
oppose: document.getElementById('oppose-count').value || '0',
neutral: document.getElementById('neutral-count').value || '0'
};
}
// Map internal group names to human-readable display names
const 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', // Corrected label
'abusefilter': 'Edit filter manager', // Added entry
'reviewer': 'Pending changes reviewer',
'accountcreator': 'Account creator',
'autoreviewer': 'Autopatrolled'
// Add other common groups here if needed for display
};
// Helper function to update count line (e.g., '''X successful candidacies so far''')
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;
// Preserve original formatting (bolding) and the text part
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 Definitions ---
const steps = [
{ // 0: Check Timing
title: 'Check Timing',
description: () => { /* ... unchanged ... */
const stepContainer = document.createElement('div');
const text = document.createElement('p');
text.innerHTML = `Verify that at least 7 days have passed since the listing on <a href="/wiki/${baseRfxPage}" target="_blank">${displayBaseRfxPage}</a>.`;
stepContainer.appendChild(text);
const timingInfoContainer = document.createElement('div');
timingInfoContainer.className = 'rfx-closer-info-box';
timingInfoContainer.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;
},
completed: false
},
{ // 1: Verify History
title: 'Verify History',
description: () => { /* ... unchanged ... */
const container = document.createElement('div');
const text = document.createElement('p');
text.textContent = 'Check the history of the transcluded page to ensure comments are genuine and haven\'t been tampered with.';
container.appendChild(text);
const historyUrl = `/w/index.php?title=${encodeURIComponent(pageName)}&action=history`;
const historyLink = document.createElement('a');
historyLink.href = historyUrl;
historyLink.target = '_blank';
historyLink.textContent = 'View page history';
historyLink.style.cssText = `display: inline-block; margin-top: 5px; padding: 5px 10px; background-color: #f8f9fa; border: 1px solid #a2a9b1; border-radius: 3px; text-decoration: none;`;
container.appendChild(historyLink);
return container;
},
completed: false
},
{ // 2: Determine Consensus
title: 'Determine Consensus',
description: () => { /* ... unchanged ... */
const stepContainer = document.createElement('div');
const staticDesc = document.createElement('p');
staticDesc.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';
stepContainer.appendChild(staticDesc);
const voteTallyContainer = document.createElement('div');
voteTallyContainer.className = 'rfx-closer-info-box';
voteTallyContainer.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;
},
completed: false
},
{ // 3: Select Outcome
title: 'Select Outcome',
description: 'Based on the consensus determination, select the appropriate outcome:',
completed: false,
isSelectionStep: true // Flag this step
},
// Step 4: Prepare Full Wikitext for Closing / Hold
{
title: 'Prepare RfX Page Wikitext', // Renamed slightly
description: (selectedOutcome, votes) => {
const container = document.createElement('div');
const loadingMsg = document.createElement('p');
loadingMsg.textContent = 'Loading and processing wikitext...';
container.appendChild(loadingMsg);
let reason = ''; // For finaltally template
let topTemplateName = ''; // For top template (rfap/rfaf/rfah)
let isHoldOutcome = false;
switch (selectedOutcome) {
case 'successful': reason = 'successful'; topTemplateName = 'rfap'; break;
case 'unsuccessful': reason = 'Unsuccessful'; topTemplateName = 'rfaf'; 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; // Set flag and reason for hold
default: return 'This step is not applicable for the selected outcome.';
}
const topTemplateCode = `{{subst:${topTemplateName}}}`;
fetchRfXWikitext().then(wikitext => {
loadingMsg.remove();
if (wikitext === null || wikitextErrorOccurred) {
// Error handling remains the same
const errorMsg = document.createElement('p');
errorMsg.innerHTML = '<strong>Error:</strong> Could not fetch or process the page wikitext. Please perform the template replacements manually. Check console for details.';
errorMsg.style.color = 'red';
container.appendChild(errorMsg);
const instructions = document.createElement('p');
instructions.innerHTML = `You will need to manually add <code>${topTemplateCode}</code> at the top and potentially perform other closing steps.`;
container.appendChild(instructions);
return;
}
let modifiedWikitext = wikitext;
// Remove any existing top templates
modifiedWikitext = modifiedWikitext.replace(/\{\{(subst:)?(rfap|rfaf|rfah|Rfa withdrawn|Rfa snow)\}\}\s*\n?/i, '');
// Add the new top template
modifiedWikitext = topTemplateCode + "\n" + modifiedWikitext;
// --- Logic for finaltally, chat link, rfab ---
const finaltallyTemplate = `'''Final <span id="rfatally">(?/?/?)</span>; ended by [[User:Barkeep49|Barkeep49]] ([[User_talk:Barkeep49|talk]]) at 22:25, 15 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]].`;
// Attempt to replace content between header and next section
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;
let headerEndIndex = -1;
if (headerMatch) {
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);
// Construct replacement content based on outcome
const replacementContent = isHoldOutcome
? `\n${chatLink}\n${finaltallyTemplate}\n` // For hold: chat link + finaltally
: `\n${finaltallyTemplate}\n`; // For closing: just finaltally
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) {
// Add footer only if it's not 'onhold'
if (!isHoldOutcome) {
modifiedWikitext = modifiedWikitext.replace(/\{\{(subst:)?rfab\}\}\s*$/i, '');
modifiedWikitext = modifiedWikitext.trim() + "\n" + footerTemplate;
}
const intro = document.createElement('p');
if (isHoldOutcome) {
intro.innerHTML = `Ensure <code>${topTemplateCode}</code> is at the top, and the 'Bureaucrat chat' link and final tally are placed correctly below the header.`;
} else {
intro.innerHTML = `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(intro);
container.appendChild(createActionLinks(pageName, modifiedWikitext, 'Full RfX Page'));
} else {
// Content replacement failed
const warning = document.createElement('p');
warning.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.';
warning.style.color = 'orange';
container.appendChild(warning);
const instructions = document.createElement('p');
let manualContent = isHoldOutcome ? `${chatLink}\n${finaltallyTemplate}` : finaltallyTemplate;
instructions.innerHTML = `Please manually replace the content between the candidate header and the next section with:`;
container.appendChild(instructions);
container.appendChild(createCopyableBox(manualContent, 'Copy content to insert manually'));
if (!isHoldOutcome) {
const footerInstruction = document.createElement('p');
footerInstruction.innerHTML = `Also ensure <code>${footerTemplate}</code> is at the very bottom.`;
container.appendChild(footerInstruction);
}
container.appendChild(createActionLinks(pageName, modifiedWikitext, 'RfX Page (Manual Edit Needed)'));
}
}).catch(err => {
loadingMsg.remove();
const errorMsg = document.createElement('p');
errorMsg.innerHTML = '<strong>Error:</strong> Error processing wikitext. Check console.';
errorMsg.style.color = 'red';
container.appendChild(errorMsg);
console.error("RfX Closer: Error in wikitext processing chain:", err);
});
return container;
},
completed: false,
showFor: ['successful', 'unsuccessful', 'withdrawn', 'notnow', 'snow', 'onhold'] // Show for all outcomes
},
// Step 5: Start Bureaucrat Chat (Only for 'onhold')
{
title: 'Start Bureaucrat Chat',
description: (selectedOutcome, votes) => {
const container = document.createElement('div');
const chatPageName = `${pageName}/Bureaucrat chat`; // Use full page name
const chatPageDisplay = `${candidateSubpage}/Bureaucrat chat`; // Shorter display name
const intro = document.createElement('p');
intro.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(intro);
const commentLabel = document.createElement('label');
commentLabel.textContent = 'Optional initial comment (will be added under == Discussion ==):';
commentLabel.style.display = 'block';
commentLabel.style.marginBottom = '4px';
container.appendChild(commentLabel);
const commentTextarea = document.createElement('textarea');
commentTextarea.className = 'rfx-crat-chat-textarea'; // Add specific class if needed
commentTextarea.placeholder = `e.g., Initiating discussion per RfX hold... [[User:Barkeep49|Barkeep49]] ([[User talk:Barkeep49|talk]]) 22:19, 15 April 2025 (UTC)`; // Changed placeholder
container.appendChild(commentTextarea);
// Function to generate the chat page wikitext
const getChatPageWikitext = () => {
const initialComment = commentTextarea.value.trim();
const discussionContent = initialComment ? initialComment : '';
return `{{Bureaucrat discussion header}}
== Discussion ==
${discussionContent}
== Recusals ==
== Summary ==`;
};
// Add Copy/Create links
container.appendChild(createActionLinks(chatPageName, getChatPageWikitext, 'Bureaucrat Chat Page', true)); // isCreateLink = true
return container;
},
completed: false,
showFor: ['onhold'] // Only show for 'onhold'
},
// NEW Step 6: Notify Bureaucrats (Only for 'onhold')
{
title: 'Notify Bureaucrats',
description: async (selectedOutcome) => {
const container = document.createElement('div');
const chatPageName = `${pageName}/Bureaucrat chat`;
const chatPageLink = `[[${chatPageName}|bureaucrat chat]]`; // Wikilink for message
const intro = document.createElement('p');
intro.innerHTML = `Notify bureaucrats about the new chat page. Edit the message below if needed, then use the API button.`;
container.appendChild(intro);
const messageTextarea = document.createElement('textarea');
messageTextarea.id = 'rfx-crat-notify-message';
messageTextarea.className = 'rfx-onhold-notify-textarea';
messageTextarea.value = `== Bureaucrat Chat ==\nYour input is requested at the freshly-created ${chatPageLink} for [[${pageName}]]. [[User:Barkeep49|Barkeep49]] ([[User talk:Barkeep49|talk]]) 22:19, 15 April 2025 (UTC)`;
container.appendChild(messageTextarea);
const buttonContainer = document.createElement('div');
const postButton = document.createElement('button');
postButton.id = 'rfx-crat-notify-button';
postButton.textContent = 'Notify Bureaucrats via API';
postButton.className = 'rfx-closer-action-button';
postButton.disabled = true; // Disabled until crat list is loaded
buttonContainer.appendChild(postButton);
const statusArea = document.createElement('div');
statusArea.id = 'rfx-crat-notify-status';
statusArea.className = 'rfx-crat-notify-status';
statusArea.textContent = 'Loading bureaucrat list...';
buttonContainer.appendChild(statusArea); // Status inside button container
container.appendChild(buttonContainer);
// Fetch crat list and enable button
let bureaucrats = [];
try {
bureaucrats = await fetchBureaucrats();
if (bureaucrats.length > 0) {
statusArea.textContent = `Ready to notify ${bureaucrats.length} bureaucrats.`;
postButton.disabled = false;
} else {
// Handle case where fetchBureaucrats returned empty list (either group empty or error occurred)
const logCheck = console.history ? console.history.find(log => log.includes("API returned an empty list") || log.includes("Could not parse bureaucrat list")) : null; // Check console history if available
if (logCheck && logCheck.includes("API returned an empty list")) {
statusArea.textContent = 'Warning: The \'bureaucrat\' group appears to be empty on this wiki.';
statusArea.style.color = 'orange';
} else {
statusArea.textContent = 'Error: Could not load bureaucrat list (or list is empty). Check console for details.';
statusArea.style.color = 'red';
}
}
} catch (error) { // Catch should already be handled in fetchBureaucrats, but double-check
statusArea.textContent = 'Error loading bureaucrat list.';
statusArea.style.color = 'red';
}
postButton.addEventListener('click', async () => {
postButton.disabled = true;
statusArea.innerHTML = 'Starting notifications... (This may take a while)<ul id="crat-notify-progress"></ul>';
const progressList = document.getElementById('crat-notify-progress');
const messageContent = messageTextarea.value.trim();
const editSummary = `Notification: Bureaucrat chat created for [[${pageName}]]${tagLine}`;
const sectionTitle = `Bureaucrat chat for ${actualCandidateUsername}`;
let successCount = 0;
let failCount = 0;
if (!messageContent) {
statusArea.textContent = 'Error: Message cannot be empty.';
statusArea.style.color = 'red';
postButton.disabled = false;
return;
}
if (bureaucrats.length === 0) {
statusArea.textContent = 'No bureaucrats found to notify.';
statusArea.style.color = 'orange';
return; // Don't proceed if list is empty
}
for (const cratUsername of bureaucrats) {
// Don't notify the closer themselves
if (cratUsername === closerUsername) {
console.log(`RfX Closer: Skipping notification for closer (${closerUsername})`);
continue;
}
const talkPage = `User talk:${cratUsername}`;
const progressItem = document.createElement('li');
progressItem.textContent = `Notifying ${cratUsername}...`;
progressList.appendChild(progressItem);
statusArea.scrollTop = statusArea.scrollHeight; // Scroll to bottom
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); // Small delay between edits
}
const finalStatus = document.createElement('p');
finalStatus.style.fontWeight = 'bold';
finalStatus.textContent = `Finished: ${successCount} successful, ${failCount} failed.`;
if (failCount > 0) {
finalStatus.style.color = 'orange';
} else {
finalStatus.style.color = 'green';
// Mark step complete only if all succeed? Or if any succeed? Let's go with any.
const stepElement = postButton.closest('.rfx-closer-step');
const stepIndex = parseInt(stepElement.dataset.step, 10);
if (!isNaN(stepIndex)) {
steps[stepIndex].completed = true;
stepElement.dataset.completed = true;
const checkbox = stepElement.querySelector('input[type="checkbox"]');
if(checkbox) checkbox.checked = true;
}
postButton.textContent = 'Notifications Sent'; // Keep disabled
}
statusArea.appendChild(finalStatus);
statusArea.scrollTop = statusArea.scrollHeight;
if (failCount > 0) postButton.disabled = false; // Re-enable if some failed
});
return container;
},
completed: false,
showFor: ['onhold'] // Only show for 'onhold'
},
// NEW Step 7: Notify Candidate (Only for 'onhold')
{
title: 'Notify Candidate (On Hold)',
description: (selectedOutcome) => {
const container = document.createElement('div');
const candidateTalkPage = `User talk:${actualCandidateUsername}`;
const chatPageName = `${pageName}/Bureaucrat chat`;
const chatPageLink = `[[${chatPageName}|bureaucrat chat]]`;
const intro = document.createElement('p');
intro.innerHTML = `Notify the candidate (${actualCandidateUsername}) about the 'on hold' status.`;
container.appendChild(intro);
const messageTextarea = document.createElement('textarea');
messageTextarea.id = 'rfx-candidate-onhold-notify-message';
messageTextarea.className = 'rfx-onhold-notify-textarea';
messageTextarea.value = `== Your ${rfxType === 'adminship' ? 'RfA' : 'RfB'} ==\nHi ${actualCandidateUsername}, just letting you know that your ${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]]) 22:19, 15 April 2025 (UTC)`;
container.appendChild(messageTextarea);
const buttonContainer = document.createElement('div');
const postButton = document.createElement('button');
postButton.id = 'rfx-candidate-onhold-notify-button';
postButton.textContent = 'Post Notification via API';
postButton.className = 'rfx-closer-action-button';
buttonContainer.appendChild(postButton);
// Add Manual Edit Link as Fallback
const manualEditLink = document.createElement('a');
manualEditLink.href = mw.util.getUrl(candidateTalkPage, { action: 'edit', section: 'new', sectiontitle: `Your ${rfxType === 'adminship' ? 'RfA' : 'RfB'}` });
manualEditLink.target = '_blank';
manualEditLink.className = 'rfx-notify-editlink'; // Reuse existing style
manualEditLink.textContent = `Post Manually...`;
buttonContainer.appendChild(manualEditLink);
const statusArea = document.createElement('div');
statusArea.id = 'rfx-candidate-onhold-notify-status';
statusArea.className = 'rfx-candidate-onhold-notify-status'; // Specific class
buttonContainer.appendChild(statusArea);
container.appendChild(buttonContainer);
postButton.addEventListener('click', async () => {
postButton.disabled = true;
statusArea.textContent = 'Posting message...';
statusArea.style.color = 'inherit';
const messageContent = messageTextarea.value.trim();
const editSummary = `Notifying candidate about RfX hold${tagLine}`;
const sectionTitle = `Your ${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';
// Mark step complete
const stepElement = postButton.closest('.rfx-closer-step');
const stepIndex = parseInt(stepElement.dataset.step, 10);
if (!isNaN(stepIndex)) {
steps[stepIndex].completed = true;
stepElement.dataset.completed = true;
const checkbox = stepElement.querySelector('input[type="checkbox"]');
if(checkbox) checkbox.checked = true;
}
postButton.textContent = 'Posted'; // Keep disabled
} else {
statusArea.textContent = `Error posting message: ${result.error}. Please post manually.`;
statusArea.style.color = 'red';
postButton.disabled = false; // Re-enable on error
}
});
return container;
},
completed: false,
showFor: ['onhold'] // Only show for 'onhold'
},
// Renumbered Step 8: Process Promotion
{
title: 'Process Promotion',
description: async (selectedOutcome) => { /* ... unchanged ... */
const container = document.createElement('div');
const text = document.createElement('p');
text.textContent = 'For successful RfXs, configure rights changes and grant via API:';
container.appendChild(text);
const loadingStatus = document.createElement('p');
loadingStatus.textContent = 'Loading current user groups...';
loadingStatus.style.fontStyle = 'italic';
container.appendChild(loadingStatus);
const rightsContainer = document.createElement('div');
container.appendChild(rightsContainer);
const grantButton = document.createElement('button');
grantButton.textContent = `Grant Rights via API`;
grantButton.className = 'rfx-closer-action-button';
grantButton.disabled = true;
const statusArea = document.createElement('div');
statusArea.className = 'rfx-closer-api-status';
statusArea.style.marginTop = '5px';
let removeCheckboxes = {};
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 = rfxType === 'adminship' ? 'sysop' : 'bureaucrat';
const groupToAddLabel = rfxType === 'adminship' ? 'Administrator' : 'Bureaucrat';
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 (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 = {};
let hasRemovable = false;
currentGroups.forEach(groupName => {
if (!groupsToExclude.includes(groupName)) {
hasRemovable = true;
const checkbox = new OO.ui.CheckboxInputWidget({ selected: true });
removeCheckboxes[groupName] = checkbox;
const displayLabel = 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 {
const noRemoveMsg = document.createElement('p');
noRemoveMsg.textContent = 'User holds no other explicit groups to potentially remove.';
noRemoveMsg.style.fontSize = '0.9em'; noRemoveMsg.style.fontStyle = 'italic';
rightsContainer.appendChild(noRemoveMsg);
}
} else {
const noRemoveMsg = document.createElement('p');
noRemoveMsg.textContent = 'No groups removed when granting bureaucrat rights.';
noRemoveMsg.style.fontSize = '0.9em'; noRemoveMsg.style.fontStyle = 'italic';
rightsContainer.appendChild(noRemoveMsg);
}
const userGroups = mw.config.get('wgUserGroups');
const canGrant = userGroups && userGroups.includes('bureaucrat');
if (canGrant) { grantButton.disabled = false; }
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';
}
grantButton.addEventListener('click', async () => {
grantButton.disabled = true; statusArea.textContent = 'Processing...'; statusArea.style.color = 'inherit';
const groupToAdd = rfxType === 'adminship' ? 'sysop' : 'bureaucrat';
const promotionReason = `Per [[${pageName}|successful RfX]]${tagLine}`;
const groupsToRemoveList = [];
if (Object.keys(removeCheckboxes).length > 0) {
for (const groupName in removeCheckboxes) { if (removeCheckboxes[groupName].isSelected()) { groupsToRemoveList.push(groupName); } }
}
const groupsToRemoveString = groupsToRemoveList.length > 0 ? groupsToRemoveList.join('|') : null;
console.log("RfX Closer: Attempting grant.", { add: groupToAdd, remove: groupsToRemoveString });
try {
const alreadyHasGroup = await getUserGroups(actualCandidateUsername).then(g => g && g.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);
let finalMessage = ""; let finalColor = "orange"; let 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 && data.userrights.removed ? data.userrights.removed.map(g => g.group) : [];
if (groupsToRemoveString) {
if (actuallyRemoved.length > 0) { finalMessage += `Removed: ${actuallyRemoved.join(', ')}.`; }
else { finalMessage += ` (No selected groups were removed).`; }
}
statusArea.textContent = finalMessage; statusArea.style.color = finalColor;
if (stepCompleted) {
const stepElement = grantButton.closest('.rfx-closer-step');
const stepIndex = parseInt(stepElement.dataset.step, 10);
if (!isNaN(stepIndex)) {
steps[stepIndex].completed = true; stepElement.dataset.completed = true;
const checkbox = stepElement.querySelector('input[type="checkbox"]'); if(checkbox) checkbox.checked = true;
}
}
} catch (error) {
console.error('Userrights API Error:', error);
const errorInfo = error?.error?.info || 'Unknown error';
statusArea.textContent = `Error: ${errorInfo}. Please grant manually.`; statusArea.style.color = 'red';
} finally { grantButton.disabled = false; }
});
container.appendChild(grantButton); container.appendChild(statusArea);
const list = document.createElement('ul'); list.style.paddingLeft = '20px'; list.style.marginTop = '10px';
const refItem = document.createElement('li'); refItem.innerHTML = `Manual link: <a href="/wiki/Special:Userrights/${encodeURIComponent(actualCandidateUsername)}" target="_blank">Special:Userrights/${actualCandidateUsername}</a>`; list.appendChild(refItem);
const rightsItem = document.createElement('li'); rightsItem.innerHTML = `Reference: <a href="/wiki/Special:ListGroupRights" target="_blank">Special:ListGroupRights</a>`; list.appendChild(rightsItem);
container.appendChild(list);
return container;
},
completed: false,
showFor: ['successful'] // Only for successful
},
// Renumbered Step 9: Update Lists
{
title: 'Update Lists',
description: async (selectedOutcome, votes) => {
const container = document.createElement('div');
// --- 1. Remove from main RfX list page ---
const removeDiv = document.createElement('div'); removeDiv.style.marginBottom = '15px';
const removeLinkText = displayBaseRfxPage;
const removeInstruction = document.createElement('p');
removeInstruction.innerHTML = `1. Update ${removeLinkText} (removing the RfX transclusion):`;
removeDiv.appendChild(removeInstruction);
const removeLoadingPara = document.createElement('p'); removeLoadingPara.textContent = ` Loading wikitext for ${removeLinkText}...`; removeLoadingPara.style.fontStyle = 'italic'; removeDiv.appendChild(removeLoadingPara); container.appendChild(removeDiv);
try {
const basePageWikitext = await fetchPageWikitext(baseRfxPage); removeLoadingPara.remove();
if (basePageWikitext !== null) {
const escapedPageNameForRegex = 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(baseRfxPage, modifiedBasePageWikitext, removeLinkText));
} else {
const warningPara = document.createElement('p'); warningPara.innerHTML = ` Warning: Could not find <code>{{${pageName}}}</code> to remove from ${removeLinkText}. Remove manually.`; warningPara.style.color = 'orange'; removeDiv.appendChild(warningPara);
removeDiv.appendChild(createActionLinks(baseRfxPage, basePageWikitext, removeLinkText));
}
} else { throw new Error(`Failed to fetch wikitext for ${baseRfxPage}`); }
} catch (error) { console.error("RfX Closer: Error processing base page wikitext:", error); removeLoadingPara.remove(); const errorPara = document.createElement('p'); errorPara.textContent = ` Error loading wikitext for ${removeLinkText}. Edit manually.`; errorPara.style.color = 'red'; removeDiv.appendChild(errorPara); }
// --- 2. Add to outcome lists ---
const addDiv = document.createElement('div'); // Container for list updates
const addInstruction = document.createElement('p');
addInstruction.innerHTML = `<strong>2. Then, add entry to appropriate list(s):</strong>`;
addDiv.appendChild(addInstruction);
let generatedListEntry = null; let generatedRfarowTemplate = null; let yearlyListPageName = ''; let alphabeticalListPageName = ''; let 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}`;
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); } }
// Use closerUsername defined globally
if (selectedOutcome === 'successful') {
isSuccessfulList = true;
yearlyListPageName = `Wikipedia:Successful ${rfxType} candidacies/${year}`;
generatedRfarowTemplate = `|{{rfarow|${actualCandidateUsername}||${formattedDate}|successful|${votes.support}|${votes.oppose}|${votes.neutral}|${closerUsername}}}`;
} else if (['unsuccessful', 'withdrawn', 'notnow', 'snow'].includes(selectedOutcome)) {
isSuccessfulList = false;
let reasonText = 'Unsuccessful';
let 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;
case 'unsuccessful': reasonText = 'Unsuccessful'; rfarowResult = 'unsuccessful'; break;
}
yearlyListPageName = `Wikipedia:Unsuccessful ${rfxType} candidacies (Chronological)/${year}`;
const firstLetter = actualCandidateUsername.charAt(0).toUpperCase(); let alphaBase = `Wikipedia:Unsuccessful ${rfxType} candidacies`;
if (rfxType === 'adminship') { if (firstLetter >= 'A' && firstLetter <= 'Z') { alphabeticalListPageName = `${alphaBase}/${firstLetter}`; } else { alphabeticalListPageName = `${alphaBase} (Alphabetical)`; } }
else { alphabeticalListPageName = `${alphaBase} (Alphabetical)`; }
const isSubsequentNomination = /[_ ]\d+$/.test(pageName); const listPrefix = isSubsequentNomination ? '*:' : '*';
generatedListEntry = `${listPrefix} [[${pageName}|${actualCandidateUsername}]] ${formattedDate} - ${reasonText} ([[User:${closerUsername}|${closerUsername}]]) (${votes.support}/${votes.oppose}/${votes.neutral})`;
generatedRfarowTemplate = `|{{rfarow|${actualCandidateUsername}||${formattedDate}|${rfarowResult}|${votes.support}|${votes.oppose}|${votes.neutral}|${closerUsername}}}`;
}
// --- Handle Yearly Page Update ---
if (yearlyListPageName) {
const yearlyDiv = document.createElement('div'); yearlyDiv.style.marginTop = '10px';
const yearlyInstruction = document.createElement('p');
yearlyInstruction.innerHTML = `2a. Update <a href="/wiki/${yearlyListPageName}" target="_blank">${yearlyListPageName}</a>:`;
yearlyDiv.appendChild(yearlyInstruction);
const knownIssueWarning = document.createElement('div');
knownIssueWarning.className = 'rfx-closer-known-issue';
knownIssueWarning.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.`;
yearlyDiv.appendChild(knownIssueWarning);
const yearlyLoading = document.createElement('p'); yearlyLoading.textContent = ` Loading...`; yearlyLoading.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 countDelta = 1;
const originalWikitextForCountCheck = modifiedYearlyWikitext; modifiedYearlyWikitext = updateCountInWikitext(modifiedYearlyWikitext, countRegex, countDelta);
if (modifiedYearlyWikitext !== originalWikitextForCountCheck) { modificationPerformed = true; countUpdated = true; } else { console.warn(`RfX Closer: Could not update count on ${yearlyListPageName}`); }
if (generatedRfarowTemplate) {
const escapedMonth = month.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const activeMonthHeaderRegex = new RegExp(`^(\\|\\-\\s*\\n\\|\\s*colspan="[\\d]+".*\\{\\{Anchord\\|${escapedMonth}\\s+${year}\\}\\}.*?)$`, 'mi');
// Regex to find commented out header (captures comment tags and the header itself)
const commentedMonthHeaderRegex = new RegExp(`^(\\s*\\s*)$`, 'mi');
let insertionIndex = -1; let headerFound = false; let originalWikitextForInsertionCheck = modifiedYearlyWikitext;
let activeMatch = modifiedYearlyWikitext.match(activeMonthHeaderRegex);
if (activeMatch) {
insertionIndex = activeMatch.index + activeMatch[0].length;
headerFound = true;
console.log(`RfX Closer: Found active month header for ${month} ${year}.`);
} else {
let commentedMatch = modifiedYearlyWikitext.match(commentedMonthHeaderRegex);
if (commentedMatch) {
uncommentAttempted = true;
const fullCommentedBlock = commentedMatch[0];
const uncommentedHeader = commentedMatch[2].replace(/^\s*\|-\s*\n/, '').replace(/\s*-->\s*$/, ''); // Extract header content
console.warn(`RfX Closer: Attempting to uncomment month header for ${month} ${year}. This might fail.`);
modifiedYearlyWikitext = modifiedYearlyWikitext.replace(fullCommentedBlock, `|-\n${uncommentedHeader}`); // Replace comment with uncommented version
modificationPerformed = true;
// Find the insertion point *after* the now-uncommented header
const searchStartIndex = commentedMatch.index; // Start search from original position
const uncommentedHeaderIndex = modifiedYearlyWikitext.indexOf(uncommentedHeader, searchStartIndex);
if (uncommentedHeaderIndex !== -1) {
insertionIndex = uncommentedHeaderIndex + uncommentedHeader.length;
headerFound = true;
console.log(`RfX Closer: Header uncommented (assumed).`);
} else {
headerFound = false; // Failed to find it after replacement
console.error(`RfX Closer: Failed to find header after attempting uncomment.`);
}
} else {
headerFound = false;
console.warn(`RfX Closer: Could not find active or commented header for ${month} ${year} on ${yearlyListPageName}.`);
}
}
if (headerFound && insertionIndex !== -1) {
let newlineIndex = modifiedYearlyWikitext.indexOf('\n', insertionIndex);
if (newlineIndex === -1) { // Header is last line
insertionIndex = modifiedYearlyWikitext.length; // Append to end
if (modifiedYearlyWikitext.length > 0 && !modifiedYearlyWikitext.endsWith('\n')) {
modifiedYearlyWikitext += '\n'; // Ensure newline before appending
insertionIndex++;
}
} else {
insertionIndex = newlineIndex + 1; // Insert on the next line
}
const insertionContent = '|-\n' + generatedRfarowTemplate + '\n';
modifiedYearlyWikitext = modifiedYearlyWikitext.substring(0, insertionIndex) + insertionContent + modifiedYearlyWikitext.substring(insertionIndex);
// Only set modificationPerformed if the insertion actually changed the text AND we didn't just uncomment
if (modifiedYearlyWikitext !== originalWikitextForInsertionCheck && !uncommentAttempted) {
modificationPerformed = true;
}
entryAdded = true;
console.log(`RfX Closer: Entry added to ${yearlyListPageName}.`);
} 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).`; }
const yearlyNote = document.createElement('p'); yearlyNote.innerHTML = noteText; yearlyDiv.appendChild(yearlyNote);
if (modificationPerformed) {
yearlyDiv.appendChild(createActionLinks(yearlyListPageName, modifiedYearlyWikitext, yearlyListPageName));
}
if (!entryAdded && generatedRfarowTemplate) { 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(); const errorPara = document.createElement('p'); errorPara.innerHTML = ` Error processing ${yearlyListPageName}. Update manually.`; errorPara.style.color = 'red'; yearlyDiv.appendChild(errorPara); if (generatedRfarowTemplate) { yearlyDiv.appendChild(createCopyableBox(generatedRfarowTemplate, `Copy row template to manually insert`)); } }
}
// --- Handle Alphabetical List Page Update ---
if (alphabeticalListPageName && !isSuccessfulList && generatedListEntry) {
const alphaDiv = document.createElement('div'); alphaDiv.style.marginTop = '15px';
const alphaInstruction = document.createElement('p');
alphaInstruction.innerHTML = `2b. Update <a href="/wiki/${alphabeticalListPageName}" target="_blank">${alphabeticalListPageName}</a>:`;
alphaDiv.appendChild(alphaInstruction);
const alphaLoading = document.createElement('p'); alphaLoading.textContent = ` Loading...`; alphaLoading.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 = ` `; // Start note on same line
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.`; }
const alphaNote = document.createElement('p'); alphaNote.innerHTML = alphaNoteText; alphaDiv.appendChild(alphaNote);
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(); const errorPara = document.createElement('p'); errorPara.innerHTML = ` Error processing ${alphabeticalListPageName}. Update manually.`; errorPara.style.color = 'red'; alphaDiv.appendChild(errorPara); if (generatedListEntry) { alphaDiv.appendChild(createCopyableBox(generatedListEntry, `Copy entry to manually insert`)); } }
}
container.appendChild(addDiv);
return container;
},
// Show only for closing outcomes (not 'onhold')
showFor: ['successful', 'unsuccessful', 'withdrawn', 'notnow', 'snow'],
completed: false
},
// Renumbered Step 10: Notify Candidate (Closing Outcomes)
{
title: 'Notify Candidate (Closing)',
description: (selectedOutcome) => {
// This step should NOT be shown for 'onhold'
if (selectedOutcome === 'onhold') {
return '(Notification for "on hold" is handled in Step 7.)';
}
const container = document.createElement('div');
const candidateTalkPage = `User talk:${actualCandidateUsername}`;
// Use closerUsername defined globally
const sectionTitle = `Outcome of your ${rfxType === 'adminship' ? 'RfA' : 'RfB'}`;
const editUrl = mw.util.getUrl(candidateTalkPage, {
action: 'edit',
section: 'new',
sectiontitle: sectionTitle
});
const intro = document.createElement('p');
intro.innerHTML = `Prepare message for <a href="/wiki/${encodeURIComponent(candidateTalkPage)}" target="_blank">${candidateTalkPage}</a>.`;
container.appendChild(intro);
const optionsDiv = document.createElement('div');
optionsDiv.className = 'rfx-notify-options';
container.appendChild(optionsDiv);
const messageTextarea = document.createElement('textarea');
messageTextarea.className = 'rfx-notify-textarea'; // Original class for this step
messageTextarea.rows = 5; // Adjust as needed
const copyBoxPlaceholder = document.createElement('div'); // Placeholder for copy box/links
const buttonContainer = document.createElement('div'); // Container for buttons/status
buttonContainer.style.marginTop = '10px';
const statusArea = document.createElement('div'); // For API status
statusArea.className = 'rfx-notify-status'; // Original class
let messageContentGetter = () => ''; // Function to get current message content
if (selectedOutcome === 'successful') {
const templateName = rfxType === 'adminship' ? 'New sysop' : 'New bureaucrat';
const defaultMessage = `{{subst:${templateName}}} [[User:Barkeep49|Barkeep49]] ([[User talk:Barkeep49|talk]]) 22:19, 15 April 2025 (UTC)`; // Use subst and signature
// Radio buttons for choice
const radioTemplateId = 'rfx-notify-template-radio';
const radioCustomId = 'rfx-notify-custom-radio';
const radioTemplateLabel = document.createElement('label');
radioTemplateLabel.htmlFor = radioTemplateId;
const radioTemplate = document.createElement('input');
radioTemplate.type = 'radio';
radioTemplate.name = 'rfx-notify-choice';
radioTemplate.id = radioTemplateId;
radioTemplate.value = 'template';
radioTemplate.checked = true; // Default to template
radioTemplateLabel.appendChild(radioTemplate);
radioTemplateLabel.appendChild(document.createTextNode(` Use {{${templateName}}}`));
optionsDiv.appendChild(radioTemplateLabel);
const radioCustomLabel = document.createElement('label');
radioCustomLabel.htmlFor = radioCustomId;
const radioCustom = document.createElement('input');
radioCustom.type = 'radio';
radioCustom.name = 'rfx-notify-choice';
radioCustom.id = radioCustomId;
radioCustom.value = 'custom';
radioCustomLabel.appendChild(radioCustom);
radioCustomLabel.appendChild(document.createTextNode(' Use custom message'));
optionsDiv.appendChild(radioCustomLabel);
// Textarea for custom message (initially hidden)
messageTextarea.value = `Congratulations! Your ${rfxType === 'adminship' ? 'RfA' : 'RfB'} was successful. [[User:Barkeep49|Barkeep49]] ([[User talk:Barkeep49|talk]]) 22:19, 15 April 2025 (UTC)`; // Default custom text with signature
messageTextarea.style.display = 'none';
container.appendChild(messageTextarea); // Add textarea to DOM
container.appendChild(copyBoxPlaceholder); // Add placeholder for copy box/links
// Function to update copy box based on radio selection
const updateSuccessActions = () => {
copyBoxPlaceholder.innerHTML = ''; // Clear previous box
if (radioTemplate.checked) {
messageContentGetter = () => defaultMessage;
// Use createActionLinks for the template
copyBoxPlaceholder.appendChild(createActionLinks(null, defaultMessage, `{{${templateName}}}`)); // No edit link needed here
} else {
messageContentGetter = () => messageTextarea.value;
// Pass the textarea element itself to createCopyableBox for live copying
copyBoxPlaceholder.appendChild(createCopyableBox(null, 'Copy the message above', false, messageTextarea));
}
};
// Event listeners for radio buttons
radioTemplate.addEventListener('change', () => {
messageTextarea.style.display = 'none';
updateSuccessActions();
});
radioCustom.addEventListener('change', () => {
messageTextarea.style.display = 'block';
updateSuccessActions();
});
// Initial copy box creation
updateSuccessActions();
} else { // Unsuccessful outcomes (withdrawn, notnow, snow, unsuccessful)
// Determine reason text for message
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;
case 'unsuccessful': reasonPhrase = 'unsuccessful'; break; // Explicitly handle standard unsuccessful
}
const defaultMessage = `Hi ${actualCandidateUsername}. I'm ${closerUsername} and I have closed your ${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 ${rfxType === 'adminship' ? 'administrator' : 'bureaucrat'} rights after initially being unsuccessful. [[User:Barkeep49|Barkeep49]] ([[User talk:Barkeep49|talk]]) 22:19, 15 April 2025 (UTC)`; // Add signature
messageTextarea.value = defaultMessage;
container.appendChild(messageTextarea); // Add textarea to DOM
container.appendChild(copyBoxPlaceholder); // Add placeholder for copy box
messageContentGetter = () => messageTextarea.value; // Always get from textarea
// Create copy box linked to the textarea
copyBoxPlaceholder.appendChild(createCopyableBox(null, 'Copy the message above', false, messageTextarea));
}
// --- API Post Button ---
const postButton = document.createElement('button');
postButton.textContent = 'Post Notification via API';
postButton.className = 'rfx-closer-action-button'; // Use existing style
buttonContainer.appendChild(postButton);
postButton.addEventListener('click', async () => {
postButton.disabled = true;
statusArea.textContent = 'Posting message...';
statusArea.style.color = 'inherit';
const messageContent = messageContentGetter(); // Get current message
const editSummary = `Notifying candidate of ${rfxType === 'adminship' ? 'RfA' : 'RfB'} outcome${tagLine}`;
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';
// Optionally mark step as complete
const stepElement = postButton.closest('.rfx-closer-step');
const stepIndex = parseInt(stepElement.dataset.step, 10);
if (!isNaN(stepIndex)) {
steps[stepIndex].completed = true;
stepElement.dataset.completed = true;
const checkbox = stepElement.querySelector('input[type="checkbox"]');
if(checkbox) checkbox.checked = true;
}
// Keep button disabled after success to prevent double-posting
postButton.textContent = 'Posted';
} else {
statusArea.textContent = `Error posting message: ${result.error}. Please post manually.`;
statusArea.style.color = 'red';
postButton.disabled = false; // Re-enable button on error
}
});
// Add Manual Edit Link as Fallback
const manualEditLink = document.createElement('a');
manualEditLink.href = editUrl;
manualEditLink.target = '_blank';
manualEditLink.className = 'rfx-notify-editlink';
manualEditLink.textContent = `Post Manually...`;
buttonContainer.appendChild(manualEditLink);
container.appendChild(buttonContainer); // Add button container
container.appendChild(statusArea); // Add status area
return container;
},
// Show only for closing outcomes (not 'onhold')
showFor: ['successful', 'unsuccessful', 'withdrawn', 'notnow', 'snow'],
completed: false
}
];
// --- Step Rendering and Logic ---
// Function to render a single step
function renderStep(step, index, selectedOutcome = '', currentVotes = {}) {
const stepElement = document.createElement('div');
stepElement.className = 'rfx-closer-step';
stepElement.dataset.step = index; // Use the actual index in the array
stepElement.dataset.completed = step.completed;
const titleElement = document.createElement('h3');
const descriptionContainer = document.createElement('div');
descriptionContainer.className = 'rfx-closer-step-description';
// Calculate display number (1-based index)
const displayIndex = index + 1;
if (step.isSelectionStep) {
// Step 3: Outcome Selector (index 3, display 4)
titleElement.textContent = `${displayIndex}. ${step.title}`;
descriptionContainer.textContent = step.description;
const templateSelector = document.createElement('select');
templateSelector.id = 'rfx-outcome-selector';
// ** UPDATED Options including 'onhold' **
templateSelector.innerHTML = `
<option value="" disabled selected>-- Select Outcome --</option>
<option value="successful">Successful</option>
<option value="unsuccessful">Unsuccessful</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);
stepElement.appendChild(titleElement);
stepElement.appendChild(descriptionContainer);
stepElement.appendChild(templateSelector);
} else {
// Other steps
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.checked = step.completed;
checkbox.dataset.stepIndex = index; // Store the actual 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}`)); // Use displayIndex
// Use Promise.resolve() to handle both async and sync description functions
Promise.resolve().then(() => {
if (typeof step.description === 'function') {
// Only call description function if the step should be shown
if (!step.showFor || step.showFor.includes(selectedOutcome)) {
// Ensure actualCandidateUsername is up-to-date before calling description
return step.description(selectedOutcome, currentVotes); // Return the promise/value
} else {
// Provide specific message if step is hidden due to outcome
if (selectedOutcome === 'onhold' && (index === 8 || index === 9)) { // Steps 9 & 10 (Update Lists, Notify Closing)
return '(This step is not applicable for the "on hold" outcome.)';
}
if (selectedOutcome !== 'successful' && index === 7) { // Step 8 (Process Promotion)
return '(This step is only applicable for successful outcomes.)';
}
if (selectedOutcome !== 'onhold' && (index === 5 || index === 6)) { // Steps 6 & 7 (Notify Crats, Notify Candidate On Hold)
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;
}
}).then(content => {
// This block runs after the description function (async or sync) completes
if (content instanceof HTMLElement || content instanceof DocumentFragment) {
// Clear previous content if re-rendering async content
while (descriptionContainer.firstChild) {
descriptionContainer.removeChild(descriptionContainer.firstChild);
}
descriptionContainer.appendChild(content);
} else {
descriptionContainer.textContent = content || '';
}
}).catch(error => {
// Handle errors from async description functions (like getUserGroups)
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 based on 'showFor' and selectedOutcome
// Steps 0-4 (display 1-5) always show.
// Others depend on outcome.
if (index <= 4) { // Steps 0, 1, 2, 3, 4 (display 1-5)
stepElement.style.display = 'block';
} else {
stepElement.style.display = (step.showFor && step.showFor.includes(selectedOutcome)) ? 'block' : 'none';
}
return stepElement;
}
// Function to render all steps
function renderAllSteps(selectedOutcome = '', currentVotes = {}) {
stepsContainer.innerHTML = ''; // Clear existing steps
steps.forEach((step, index) => {
const stepElement = renderStep(step, index, selectedOutcome, currentVotes);
stepsContainer.appendChild(stepElement);
});
// Re-attach selector value
const selector = document.getElementById('rfx-outcome-selector');
if (selector) selector.value = selectedOutcome;
}
// Event handler for outcome selection change
function handleOutcomeChange(event) {
const selectedOutcome = event.target.value;
const currentVotes = getCurrentVoteCounts();
// Mark step 3 (index 3) as completed if an outcome is selected
if (steps[3]) { // Check if step exists (it should)
steps[3].completed = selectedOutcome !== '';
}
// Determine if it's a closing outcome (not 'onhold' or empty)
// This affects whether list pages are pre-fetched
const isClosingOutcome = selectedOutcome && !['', 'onhold'].includes(selectedOutcome);
// Pre-fetch wikitext if needed
const promises = [fetchRfaData()]; // Ensure summary data promise is always included
// Always fetch the RfX page itself if an outcome is selected (needed for Step 4)
if (selectedOutcome) {
promises.push(fetchRfXWikitext());
}
if (isClosingOutcome) {
// Fetch pages needed for closing (base list, yearly list, alpha list)
promises.push(fetchPageWikitext(baseRfxPage));
const year = new Date().getFullYear();
let yearlyListPageName = '';
let alphabeticalListPageName = '';
if (selectedOutcome === 'successful') {
yearlyListPageName = `Wikipedia:Successful ${rfxType} candidacies/${year}`;
} else if (['unsuccessful', 'withdrawn', 'notnow', 'snow'].includes(selectedOutcome)) {
yearlyListPageName = `Wikipedia:Unsuccessful ${rfxType} candidacies (Chronological)/${year}`;
const firstLetter = actualCandidateUsername.charAt(0).toUpperCase();
let alphaBase = `Wikipedia:Unsuccessful ${rfxType} candidacies`;
if (rfxType === 'adminship') {
if (firstLetter >= 'A' && firstLetter <= 'Z') {
alphabeticalListPageName = `${alphaBase}/${firstLetter}`;
} else {
alphabeticalListPageName = `${alphaBase} (Alphabetical)`;
}
} else {
alphabeticalListPageName = `${alphaBase} (Alphabetical)`;
}
}
if (yearlyListPageName) {
promises.push(fetchPageWikitext(yearlyListPageName));
}
if (alphabeticalListPageName) {
promises.push(fetchPageWikitext(alphabeticalListPageName));
}
} else if (selectedOutcome === 'onhold') {
// For 'onhold', pre-fetch bureaucrat list (needed for Step 6)
promises.push(fetchBureaucrats());
}
// Wait for necessary data fetches before re-rendering
Promise.all(promises).then(() => {
// Re-render all steps with the new outcome and current votes
renderAllSteps(selectedOutcome, currentVotes);
}).catch(error => {
console.error("RfX Closer: Error during pre-fetch in handleOutcomeChange:", error);
// Still try to render, steps should handle their own errors
renderAllSteps(selectedOutcome, currentVotes);
});
}
// --- Event Listeners ---
// Draggable Header
let isDragging = false;
let dragStartX, dragStartY, containerStartX, containerStartY;
header.addEventListener('mousedown', (e) => {
// Only drag if not clicking on a button inside the header
if (e.target.tagName === 'BUTTON') return;
isDragging = true;
dragStartX = e.clientX;
dragStartY = e.clientY;
containerStartX = container.offsetLeft;
containerStartY = container.offsetTop;
container.style.cursor = 'grabbing';
container.style.userSelect = 'none'; // Prevent text selection while dragging
// Ensure position is fixed or absolute for dragging
if (window.getComputedStyle(container).position === 'static') {
container.style.position = 'fixed'; // Or 'absolute' depending on desired behavior
// Recalculate start positions if position changed
containerStartX = container.offsetLeft;
containerStartY = container.offsetTop;
}
// Reset transform if using top/left for positioning
container.style.transform = '';
// Set initial top/left based on current position
container.style.left = containerStartX + 'px';
container.style.top = containerStartY + 'px';
});
document.addEventListener('mousemove', (e) => {
if (!isDragging) return;
const dx = e.clientX - dragStartX;
const dy = e.clientY - dragStartY;
container.style.left = (containerStartX + dx) + 'px';
container.style.top = (containerStartY + dy) + 'px';
});
document.addEventListener('mouseup', () => {
if (isDragging) {
isDragging = false;
container.style.cursor = 'grab';
container.style.userSelect = '';
}
});
// Collapse/Expand Button
let isCollapsed = false;
collapseButton.addEventListener('click', () => {
isCollapsed = !isCollapsed;
contentAndInputContainer.style.display = isCollapsed ? 'none' : 'flex';
collapseButton.innerHTML = isCollapsed ? '+' : '−';
// If using top/left for dragging, don't reset top/transform here
// container.style.top = isCollapsed ? '10px' : '50%';
// container.style.transform = isCollapsed ? 'translateY(0)' : 'translateY(-50%)';
container.style.maxHeight = isCollapsed ? '' : '90vh';
});
// Close Button
closeButton.addEventListener('click', () => {
container.style.display = 'none';
});
// Launch Button (created later)
// --- Initialization ---
// Add mw.util dependency for getUrl
mw.loader.using(['oojs-ui-core', 'oojs-ui-widgets', 'mediawiki.api', 'mediawiki.widgets.DateInputWidget', 'mediawiki.util'], function() {
// Fetch summary data first, then render initial steps
fetchRfaData().then(() => {
updateInputFields(); // Update inputs based on fetched data
// Don't fetch wikitext until needed (outcome selected)
renderAllSteps(); // Initial render
// --- Add event listeners for dynamic percentage update ---
const supportInput = document.getElementById('support-count');
const opposeInput = document.getElementById('oppose-count');
if (supportInput) {
supportInput.addEventListener('input', updatePercentageDisplay);
}
if (opposeInput) {
opposeInput.addEventListener('input', updatePercentageDisplay);
}
});
// Create and add launch button to the toolbar
const launchButton = document.createElement('a');
launchButton.id = 'rfx-closer-launch';
launchButton.textContent = 'RfX Closer';
launchButton.href = '#'; // Prevent page jump
launchButton.addEventListener('click', (e) => {
e.preventDefault();
if (container.style.display === 'none' || container.style.display === '') {
// Fetch summary data if not already fetched or if an error occurred
if (!rfaData || fetchErrorOccurred) {
fetchRfaData().then(() => {
updateInputFields();
// Re-render steps based on new data and potentially selected outcome
renderAllSteps(document.getElementById('rfx-outcome-selector')?.value || '', getCurrentVoteCounts());
container.style.display = 'flex';
});
} else {
// Data exists, just show
container.style.display = 'flex';
}
} else {
container.style.display = 'none';
}
});
// Add launch button to the 'Tools' menu (p-tb)
console.log("RfX Closer: Attempting to add launch button..."); // DEBUGGING
const pageTools = document.querySelector('#p-tb ul');
if (pageTools) {
const li = document.createElement('li');
li.id = 'rfx-closer-launch-li';
li.appendChild(launchButton);
pageTools.appendChild(li);
console.log("RfX Closer: Launch button potentially added."); // DEBUGGING
} else {
// Log if the tools menu wasn't found
console.warn("RfX Closer: Could not find the tools menu ('#p-tb ul') to add the launch button.");
}
}); // End mw.loader.using
})();