Note: After saving, you have to bypass your browser's cache to see the changes. Google Chrome, Firefox, Microsoft Edge and Safari: Hold down the ⇧ Shift key and click the Reload toolbar button. For details and instructions about other browsers, see Wikipedia:Bypass your cache.
// <nowiki>

// === Compiled with Novem Linguae's publish.php script ======================

// === modules/UnblockReview.js ======================================================

class UnblockReview {
	constructor() {
		this.SIGNATURE = '~~~~';
	}

	/**
	 * Process the accept or decline of an unblock request.
	 *
	 * @param {string} wikitext - The wikitext of the page.
	 * @param {string} paramsAndReason - The parameters and reason of the unblock request, e.g.
	 *                                   "NewUsername|Reason" or "Reason". The initial pipe is omitted.
	 * @param {string} acceptDeclineReason - The reason for accepting or declining the unblock request.
	 * @param {string} DEFAULT_DECLINE_REASON - The default reason for declining the unblock request.
	 * @param {string} acceptOrDecline - Either "accept" or "decline".
	 * @return {string} wikitext
	 */
	processAcceptOrDecline( wikitext, paramsAndReason, acceptDeclineReason, DEFAULT_DECLINE_REASON, acceptOrDecline ) {
		// HTML does one line break and wikitext does 2ish. Cut off all text after the first line break to avoid breaking our search algorithm.
		paramsAndReason = paramsAndReason.split( '\n' )[ 0 ];

		let initialText = '';
		// Special case: If the user didn't provide a reason, the template will display "Please provide a reason as to why you should be unblocked", and this will be detected as the appealReason.
		const reasonIsProvided = !paramsAndReason.startsWith( 'Please provide a reason as to why you should be unblocked' );
		if ( !reasonIsProvided ) {
			initialText = wikitext.match( /(\{\{Unblock)\}\}/i )[ 1 ];
			paramsAndReason = '';
		} else {
			initialText = this.getLeftHalfOfUnblockTemplate( wikitext, paramsAndReason );
		}

		// if reason is blank, use the default decline reason
		if ( !acceptDeclineReason.trim() && acceptOrDecline === 'decline' ) {
			acceptDeclineReason = DEFAULT_DECLINE_REASON;
		}

		// if someone accepts using the default decline reason, blank it.
		// we can't combine this line with the above because the default decline reason can also be entered by the user, and this is common because it's the default reason in the HTML form.
		if ( acceptOrDecline === 'accept' && acceptDeclineReason === DEFAULT_DECLINE_REASON ) {
			acceptDeclineReason = '';
		}

		// add signature if not present, including if the reason is blank
		if ( acceptDeclineReason && !this.hasSignature( acceptDeclineReason ) ) {
			acceptDeclineReason += ' ' + this.SIGNATURE;
		} else if ( !acceptDeclineReason ) {
			acceptDeclineReason = this.SIGNATURE;
		}

		// eslint-disable-next-line no-useless-concat
		const negativeLookbehinds = '(?<!<' + 'nowiki>)';
		const regEx = new RegExp( negativeLookbehinds + this.escapeRegExp( initialText + paramsAndReason ), 'g' );
		const count = ( wikitext.match( regEx ) || [] ).length;
		if ( count > 1 ) {
			throw new Error( 'Too many matching unblock templates found' );
		}

		const templateName = initialText.match( /^\{\{([A-Za-z-]+)/i )[ 1 ];
		let wikitext2 = wikitext.replace(
			regEx,
			// Always add 1=. That way if there's an unescaped equals sign after it, it won't break things.
			'{{' + templateName + ' reviewed|' + acceptOrDecline + '=' + acceptDeclineReason + '|1=' + paramsAndReason
		);

		// {{Unblock-spamun}} has an extra parameter for the new username. Example:
		// {{Unblock-spamun|NewUsername|Your reason here}}
		// So the output need to have a 2= instead of a 1=. Example:
		// {{Unblock-spamun reviewed|accept=I accept. ~~~~|NewUsername|2=Your reason here}}
		const hasExtraUsernameParameter = templateName.toLowerCase() === 'unblock-spamun';
		if ( hasExtraUsernameParameter ) {
			// delete the first 1=
			wikitext2 = wikitext2.replace( '|1=', '|' );
			// add a 2= after the third pipe
			const thirdPipePos = this.getPosition( wikitext2, '|', 3 );
			wikitext2 = wikitext2.slice( 0, thirdPipePos ) + '|2=' + wikitext2.slice( thirdPipePos + 1 );
		}

		if ( wikitext === wikitext2 ) {
			throw new Error( 'Replacing text with unblock message failed' );
		}

		// get rid of any [#*:] in front of {{unblock X}} templates. indentation messes up the background color and border of the unblock template.
		wikitext2 = wikitext2.replace( /^[#*: ]{1,}(\{\{\s*unblock)/gmi, '$1' );

		return wikitext2;
	}

	/**
	 * @copyright Denys Seguret, CC BY-SA 4.0, https://stackoverflow.com/a/14480366/3480193
	 */
	getPosition( haystack, needle, nthOccurrence ) {
		return haystack.split( needle, nthOccurrence ).join( needle ).length;
	}

	/**
	 * Given the wikitext of an entire page, and the |reason= parameter of one of the many unblock templates (e.g. {{Unblock}}, {{Unblock-un}}, {{Unblock-auto}}, {{Unblock-bot}}, etc.), return the wikitext of just the beginning of the template.
	 *
	 * For example, "Test {{unblock|reason=Your reason here [[User:Filipe46]]}} Test" as the wikitext and "Your reason here" as the appealReason will return "{{unblock|reason=".
	 *
	 * This can also handle 1=, and no parameter at all (just a pipe)
	 */
	getLeftHalfOfUnblockTemplate( wikitext, appealReason ) {
		// Isolate the reason, stripping out all template syntax. So `{{Unblock|reason=ABC}}` becomes matches = [ 'ABC ']
		// eslint-disable-next-line no-useless-concat
		const negativeLookbehinds = '(?<!<' + 'nowiki>{{unblock\\|reason=)(?<!reviewed ?\\|1=)';
		const regEx = new RegExp( negativeLookbehinds + this.escapeRegExp( appealReason ), 'g' );
		let matches = wikitext.matchAll( regEx );
		matches = [ ...matches ];

		if ( matches.length === 0 ) {
			throw new Error( 'Searching for target text failed' );
		}

		// Loop through all the potential matches, trying to find an {{Unblock template. If found, return the beginning of the template.
		for ( const match of matches ) {
			const matchPos = match.index;
			let unblockTemplateStartPos;

			// Scan backwards from the match until we find {{
			// Stop at the beginning of the string OR after 50 characters
			const stopPos = Math.max( 0, matchPos - 50 );
			for ( let i = matchPos; i > stopPos; i-- ) {
				if ( wikitext[ i ] === '{' && wikitext[ i - 1 ] === '{' ) {
					unblockTemplateStartPos = i - 1;
					break;
				}
			}

			// Don't match stuff that isn't an unblock template
			const initialText = wikitext.slice( unblockTemplateStartPos, matchPos );
			if ( !initialText.match( /^\{\{unblock/i ) ) {
				continue;
			}

			// Don't match already reviewed templates. These are marked with the word "reviewed". Example: {{unblock-spamun reviewed|
			if ( initialText.match( /\s+reviewed\s*\|/ ) ) {
				continue;
			}

			return initialText;
		}

		throw new Error( 'Failed to find left half of unblock template in the wikicode' );
	}

	/**
	 * @copyright coolaj86, CC BY-SA 4.0, https://stackoverflow.com/a/6969486/3480193
	 */
	escapeRegExp( string ) {
		// $& means the whole matched string
		return string.replace( /[.*+?^${}()|[\]\\]/g, '\\$&' );
	}

	/**
	 * Is there a signature (four tildes) present in the given text, outside of a nowiki element?
	 */
	hasSignature( text ) {
		// no literal signature?
		if ( !text.includes( this.SIGNATURE ) ) {
			return false;
		}

		// if there's a literal signature and no nowiki elements,
		// there must be a real signature
		// eslint-disable-next-line no-useless-concat
		if ( !text.includes( '<no' + 'wiki>' ) ) {
			return true;
		}

		// Save all nowiki spans
		const nowikiSpanStarts = []; // list of ignored span beginnings
		const nowikiSpanLengths = []; // list of ignored span lengths
		// eslint-disable-next-line no-useless-concat
		const NOWIKI_RE = new RegExp( '<no' + 'wiki>.*?</no' + 'wiki>', 'g' );
		let spanMatch;
		do {
			spanMatch = NOWIKI_RE.exec( text );
			if ( spanMatch ) {
				nowikiSpanStarts.push( spanMatch.index );
				nowikiSpanLengths.push( spanMatch[ 0 ].length );
			}
		} while ( spanMatch );

		// So that we don't check every ignore span every time
		let nowikiSpanStartIdx = 0;

		const SIG_RE = new RegExp( this.SIGNATURE, 'g' );
		let sigMatch;

		matchLoop:
		do {
			sigMatch = SIG_RE.exec( text );
			if ( sigMatch ) {
				// Check that we're not inside a nowiki
				for ( let nwIdx = nowikiSpanStartIdx; nwIdx <
					nowikiSpanStarts.length; nwIdx++ ) {
					if ( sigMatch.index > nowikiSpanStarts[ nwIdx ] ) {
						if ( sigMatch.index + sigMatch[ 0 ].length <=
							nowikiSpanStarts[ nwIdx ] + nowikiSpanLengths[ nwIdx ] ) {

							// Invalid sig
							continue matchLoop;
						} else {
							// We'll never encounter this span again, since
							// headers only get later and later in the wikitext
							nowikiSpanStartIdx = nwIdx;
						}
					}
				}

				// We aren't inside a nowiki
				return true;
			}
		} while ( sigMatch );
		return false;
	}
}
$(async function() {

// === main.js ======================================================

/*
Forked from [[User:Enterprisey/unblock-review.js]] on Oct 31, 2024.
Many additional bugs fixed.
*/

// 
( async function () {
	const UNBLOCK_REQ_COLOR_PRE_2025 = 'rgb(235, 244, 255)';
	const UNBLOCK_REQ_COLOR_POST_2025 = 'var(--background-color-progressive-subtle, #EBF4FF)';
	const UNBLOCK_SPAMUN_COLOR = 'var(--background-color-progressive-subtle, #f1f4fd)';
	const DEFAULT_DECLINE_REASON = '{{subst:Decline reason here}}';
	const ADVERT = ' ([[User:Novem Linguae/Scripts/UnblockReview.js|unblock-review]])';

	async function execute() {
		const userTalkNamespace = 3;
		if ( mw.config.get( 'wgNamespaceNumber' ) !== userTalkNamespace ) {
			return;
		}

		// look for user-block HTML class, which will correspond to {{Unblock}} requests
		const userBlockBoxes = document.querySelectorAll( 'div.user-block' );
		for ( let i = 0, n = userBlockBoxes.length; i < n; i++ ) {
			if (
				userBlockBoxes[ i ].style[ 'background-color' ] === UNBLOCK_REQ_COLOR_PRE_2025 ||
				userBlockBoxes[ i ].style.background === UNBLOCK_REQ_COLOR_POST_2025 ||
				userBlockBoxes[ i ].style.background === UNBLOCK_SPAMUN_COLOR
			) {
				// We now have a pending unblock request - add UI
				const unblockDiv = userBlockBoxes[ i ];
				const [ container, hrEl ] = addTextBoxAndButtons( unblockDiv );
				await listenForAcceptAndDecline( container, hrEl );
			}
		}
	}

	function addTextBoxAndButtons( unblockDiv ) {
		mw.util.addCSS( `
			.unblock-review td { padding: 0 }
			td.reason-container { padding-right: 1em; width: 30em }
			#unblock-review-autoadd-template { width: 31em; background-color: white; border: 1px solid black; }
			.unblock-review-reason { height: 5em }
		` );

		const container = document.createElement( 'table' );
		container.className = 'unblock-review';
		// Note: The innerHtml of the button is sensitive. Is used to figure out which accept/decline wikitext to use. Don't add whitespace to it.
		container.innerHTML = `
			<tr>
				<td class="reason-container" rowspan="2">
					<textarea class="unblock-review-reason mw-ui-input" placeholder="Reason for accepting/declining here">${ DEFAULT_DECLINE_REASON }</textarea>
				</td>
				<td>
					<button class="unblock-review-accept mw-ui-button mw-ui-progressive">Accept</button>
				</td>
			</tr>
			<tr>
				<td>
					<button class="unblock-review-decline mw-ui-button mw-ui-destructive">Decline</button>
				</td>
			</tr>
			<select id="unblock-review-autoadd-template">
				<option value="default" selected disabled>Select one of these templates to auto-add it above...</option>
				<option>{{subst:2nd chance}}</option>
				<option>{{subst:2nd chance autoload}}</option>
				<option>{{subst:2nd chance autoload/editintro}}</option>
				<option>{{subst:Decline reason here}}</option>
				<option>{{subst:Decline spam unblock request}}</option>
				<option>{{subst:Decline stale}}</option>
				<option>{{subst:Decline-ai}}</option>
				<option>{{subst:Declined unblock request for range block text}}</option>
			</select>
			`;
		const hrEl = unblockDiv.querySelector( 'hr' );
		unblockDiv.insertBefore( container, hrEl.previousElementSibling );

		// When a template is selected from the dropdown, insert its display text into the reason textarea.
		$( container ).find( '#unblock-review-autoadd-template' ).on( 'change', function () {
			// eslint-disable-next-line no-jquery/no-sizzle
			const templateText = $( this ).find( 'option:selected' ).text() || $( this ).val() || '';
			$( container ).find( '.unblock-review-reason' ).val( templateText );
		} );

		return [ container, hrEl ];
	}

	async function listenForAcceptAndDecline( container, hrEl ) {
		const reasonArea = container.querySelector( 'textarea' );
		$( container ).find( 'button' ).on( 'click', async function () {
			// look at the innerHtml of the button to see if it says "Accept" or "Decline"
			const acceptOrDecline = $( this ).text().toLowerCase();
			const appealReason = hrEl.nextElementSibling.nextElementSibling.childNodes[ 0 ].textContent;
			// FIXME: should handle this case (|reason=\nText, https://github.com/NovemLinguae/UserScripts/issues/240) instead of throwing an error
			if ( appealReason === '\n' ) {
				mw.notify( 'UnblockReview error: unable to find decline reason by scanning HTML', { type: 'error' } );
				return;
			}

			// change wikitext
			// eslint-disable-next-line no-undef
			const unblockReview = new UnblockReview();
			const title = mw.config.get( 'wgPageName' );
			const wikitext = await getWikitext( title );
			const acceptDeclineReason = reasonArea.value;
			let wikitext2;
			try {
				wikitext2 = unblockReview.processAcceptOrDecline(
					wikitext,
					appealReason,
					acceptDeclineReason,
					DEFAULT_DECLINE_REASON,
					acceptOrDecline
				);
			} catch ( e ) {
				mw.notify( 'UnblockReview error: ' + e.message, { type: 'error' } );
				return;
			}

			if ( wikitext === wikitext2 ) {
				mw.notify( 'UnblockReview error: unable to determine write location.', { type: 'error' } );
				return;
			}

			const acceptingOrDeclining = ( acceptOrDecline === 'accept' ? 'Accepting' : 'Declining' );
			const editSummary = acceptingOrDeclining + ' unblock request' + ADVERT;
			await editPage( title, wikitext2, editSummary );
			window.location.reload( true );
		} );
	}

	async function getWikitext( title ) {
		const data = await ( new mw.Api() ).get( {
			format: 'json',
			action: 'query',
			prop: 'revisions',
			rvprop: 'content',
			rvlimit: 1,
			titles: title
		} );
		const pageId = Object.keys( data.query.pages )[ 0 ];
		const wikitext = data.query.pages[ pageId ].revisions[ 0 ][ '*' ];
		return wikitext;
	}

	async function editPage( title, wikitext, editSummary ) {
		await ( new mw.Api() ).postWithToken( 'csrf', {
			action: 'edit',
			title: title,
			summary: editSummary,
			text: wikitext
		} );
	}

	$( async () => {
		await mw.loader.using( [ 'mediawiki.api', 'mediawiki.util', 'mediawiki.ui.button' ] ).then( async () => {
			await execute();
		} );
	} );
}() );
// 


});

// </nowiki>