Toggle menu
Toggle preferences menu
Toggle personal menu
Not logged in
Your IP address will be publicly visible if you make any edits.

MediaWiki:Common.js: Difference between revisions

MediaWiki interface page
No edit summary
No edit summary
Line 475: Line 475:
var $intro = $( '<div>' ).css( 'margin-bottom', '6px' ).text(
var $intro = $( '<div>' ).css( 'margin-bottom', '6px' ).text(
'Filing without the street number? Say why the exact address is being ' +
'Filing without the street number? Say why the exact address is being ' +
'withheld.
'withheld.'
);
);
var $reason = $( '<textarea>' ).attr( 'rows', 3 )
var $reason = $( '<textarea>' ).attr( 'rows', 3 )

Revision as of 00:19, 25 July 2026

( function () {
	'use strict';

	var GAR_NS = 3000; // NS_GAR

	/* ----------------------------------------------------------
	   Case review. A case created by a contributor carries the
	   pending category and stays invisible to the public until a
	   reviewer approves it. The server enforces both halves; this
	   is only the interface for it.
	   ---------------------------------------------------------- */
	var GAR_PENDING_CAT = 'Pending cases';
	var GAR_PENDING_TAG = '[[Category:Pending cases]]';
	// A case is PRIVATE unless it carries this marker. Nothing is required at
	// creation, so a case can never fail to be created -- and if anything here
	// breaks, a case stays hidden instead of leaking.
	var GAR_PUBLISHED_CAT = 'Published cases';
	var GAR_PUBLISHED_TAG = '[[Category:Published cases]]';

	// Does a title need a reviewer's approval before the public can see it?
	// Two kinds: case pages, and court guidance pages (the level below a
	// region, e.g. GAR:Court/C/Canada/Ontario/Small Claims Court).
	function garTitleNeedsReview( title ) {
		var t = String( title || '' ).replace( /_/g, ' ' );
		if ( t.indexOf( '/Case-' ) !== -1 ) {
			return true;
		}
		return t.indexOf( 'GAR:Court/' ) === 0 &&
			( t.split( '/' ).length - 1 ) >= 4;
	}

	// Is the page being viewed one of those?
	function garOnCasePage() {
		return garTitleNeedsReview( mw.config.get( 'wgPageName' ) );
	}

	// Has this case been published by a reviewer?
	function garIsPublished() {
		var cats = mw.config.get( 'wgCategories' ) || [];
		return cats.indexOf( GAR_PUBLISHED_CAT ) !== -1;
	}

	// Groups that hold the 'moderation' right server-side.
	function garIsReviewer() {
		var g = mw.config.get( 'wgUserGroups' ) || [];
		return g.indexOf( 'sysop' ) !== -1 ||
			g.indexOf( 'bureaucrat' ) !== -1 ||
			g.indexOf( 'moderator' ) !== -1;
	}

	/* ==========================================================
	   Safety helpers
	   ========================================================== */

	// Neutralise wiki + HTML characters so a typed value cannot inject
	// templates, links, parameters, or parser/HTML tags.
	function escapeWikitext( str ) {
		return String( str )
			.replace( /\{/g, '&#123;' )
			.replace( /\}/g, '&#125;' )
			.replace( /\[/g, '&#91;' )
			.replace( /\]/g, '&#93;' )
			.replace( /\|/g, '&#124;' )
			.replace( /</g, '&lt;' )
			.replace( />/g, '&gt;' );
	}

	// Escape $ so a value is safe inside a JS String.replace() replacement.
	function safeReplacement( str ) {
		return str.replace( /\$/g, '$$$$' );
	}

	// Reject a typed page name that contains title-illegal characters or a
	// slash (which would silently create an off-structure subpage). Returns
	// a cleaned name, or null if it must be refused.
	function sanitizeName( name ) {
		var n = String( name )
			// Remove characters that are invisible or that reverse how the
			// rest of the name reads. MediaWiki accepts these in a title, so
			// nothing else strips them, and they let one title be displayed
			// as though it were a different one.
			.replace(
				/[\u0000-\u001F\u007F-\u009F\u00AD\u200B-\u200F\u202A-\u202E\u2060-\u2064\u206A-\u206F\uFEFF]/g,
				''
			)
			.replace( /\s+/g, ' ' )
			.trim();
		if ( !n || n.length > 100 ) {
			return null;
		}
		// Characters MediaWiki forbids in a title, plus ones that would change
		// which page is addressed: / builds an unintended subpage, : looks like
		// a namespace or interwiki prefix, % re-encodes, ~ signs, . walks paths.
		if ( /[#<>\[\]{}|/:%~]/.test( n ) ) {
			return null;
		}
		if ( n.charAt( 0 ) === '.' ) {
			return null;
		}
		return n;
	}

	// Filenames: same idea, but a filename legitimately may contain dots.
	function sanitizeFilename( name ) {
		var n = String( name ).replace( /\s+/g, ' ' ).trim();
		if ( !n ) {
			return null;
		}
		if ( /[#<>\[\]{}|]/.test( n ) ) {
			return null;
		}
		return n;
	}

	// Strip a leading "Namespace:" from a title, leaving the bare path.
	// (Titles come back from the API with spaces, e.g. "GAR:YouTube/A".)
	function stripNs( title ) {
		var t = String( title );
		var idx = t.indexOf( ':' );
		return idx > -1 ? t.slice( idx + 1 ) : t;
	}

	// If the create-prefix ends in a single A-Z letter, return it (else null).
	function bucketLetterFromPrefix( prefix ) {
		var p = String( prefix ).replace( /\/$/, '' );
		var seg = p.split( '/' ).pop();
		return /^[A-Za-z]$/.test( seg ) ? seg.toUpperCase() : null;
	}

	// First letter of a typed name, accents removed, upper-cased.
	function firstLetter( name ) {
		return name.trim().charAt( 0 ).normalize( 'NFD' ).replace( /[\u0300-\u036f]/g, '' ).toUpperCase();
	}

	// Zero-pad a case number to 11 digits: first case = Case-00000000001.
	var CASE_DIGITS = 11;
	function padCase( n ) {
		var s = String( n );
		while ( s.length < CASE_DIGITS ) {
			s = '0' + s;
		}
		return s;
	}

	// Mimic MediaWiki's preload transform on raw template text:
	//   <onlyinclude> wins, <noinclude> removed, <includeonly> unwrapped.
	// Lets preload templates be wrapped in <includeonly> (so the template
	// page itself renders empty and joins no category) while the real page
	// still receives clean wikitext.
	function stripPreload( text ) {
		var only = text.match( /<onlyinclude>([\s\S]*?)<\/onlyinclude>/gi );
		if ( only ) {
			text = only.map( function ( s ) {
				return s.replace( /<\/?onlyinclude>/gi, '' );
			} ).join( '' );
		}
		text = text.replace( /<noinclude>[\s\S]*?<\/noinclude>/gi, '' );
		text = text.replace( /<\/?includeonly>/gi, '' );
		return text.replace( /^\n+/, '' );
	}

	function markDone( $el ) {
		if ( $el.data( 'garDone' ) ) {
			return false;
		}
		$el.data( 'garDone', true );
		return true;
	}

	/* ==========================================================
	   Live auto-updating grid. Lists real GAR pages by their
	   subpage structure (not categories), so nothing has to be
	   "registered" and template pages can never appear as tiles.

	   Two modes, set on the div:
	     data-listmode="platforms"  -> top-level GAR pages (no "/"),
	                                   excluding the system pages.
	     data-listmode="children"   -> the direct sub-pages of
	                                   data-parent (its immediate
	                                   children only, not grandchildren).
	   ========================================================== */
	var GAR_SYSTEM_PAGES = { 'Main': true, 'Offline': true, 'Police': true, 'Court': true, 'Platforms': true };

	function renderAutoGrid( $container ) {
		if ( !markDone( $container ) ) {
			return;
		}
		var mode = $container.data( 'listmode' ) || 'children';
		$container.text( 'Loading...' );

		mw.loader.using( 'mediawiki.api' ).then( function () {
			var api = new mw.Api();

			if ( mode === 'platforms' ) {
				return api.get( {
					action: 'query',
					list: 'allpages',
					apnamespace: GAR_NS,
					aplimit: 'max',
					formatversion: 2,
					format: 'json'
				} ).then( function ( data ) {
					var pages = ( data.query && data.query.allpages ) || [];
					var items = [];
					pages.forEach( function ( p ) {
						var path = stripNs( p.title );
						if ( path.indexOf( '/' ) === -1 && !GAR_SYSTEM_PAGES[ path ] ) {
							items.push( { title: p.title, label: path } );
						}
					} );
					return items;
				} );
			}

			// mode === 'children'
			var parent = $container.data( 'parent' ) || mw.config.get( 'wgPageName' );
			var parentPath = stripNs( String( parent ).replace( /_/g, ' ' ) );
			var prefix = parentPath + '/';
			return api.get( {
				action: 'query',
				list: 'allpages',
				apnamespace: GAR_NS,
				apprefix: prefix,
				aplimit: 'max',
				formatversion: 2,
				format: 'json'
			} ).then( function ( data ) {
				var pages = ( data.query && data.query.allpages ) || [];
				var items = [];
				pages.forEach( function ( p ) {
					var path = stripNs( p.title );
					if ( path.indexOf( prefix ) === 0 ) {
						var rest = path.slice( prefix.length );
						if ( rest && rest.indexOf( '/' ) === -1 ) {
							items.push( { title: p.title, label: rest } );
						}
					}
				} );
				return items;
			} );
		} ).then( function ( items ) {
			// Cases still awaiting review are not listed for the public.
			if ( garIsReviewer() || !items || items.length === 0 ) {
				return items;
			}
			return new mw.Api().get( {
				action: 'query',
				list: 'categorymembers',
				cmtitle: 'Category:' + GAR_PENDING_CAT,
				cmnamespace: GAR_NS,
				cmlimit: 'max',
				formatversion: 2,
				format: 'json'
			} ).then( function ( d ) {
				var pending = {};
				( ( d.query && d.query.categorymembers ) || [] ).forEach( function ( p ) {
					pending[ p.title ] = true;
				} );
				return items.filter( function ( it ) {
					return !pending[ it.title ];
				} );
			}, function () {
				return items;   // if the check fails, fall back to the full list
			} );
		} ).then( function ( items ) {
			$container.empty();
			if ( !items || items.length === 0 ) {
				$container.append( $( '<p>' ).text( 'Nothing has been added here yet.' ) );
				return;
			}
			items.sort( function ( a, b ) {
				return a.label.localeCompare( b.label );
			} );
			items.forEach( function ( it ) {
				$container.append(
					$( '<a>' ).attr( 'href', mw.util.getUrl( it.title ) ).text( it.label )
				);
			} );
		}, function () {
			$container.text( 'Could not load this list right now.' );
		} );
	}

	/* ==========================================================
	   Instant-create core
	   ========================================================== */
	function fallbackToManualEditor( newTitle, preloadTitle, $status, reason ) {
		console.error( 'GAR instant-create fell back to manual editor. Reason: ' + reason + '. Title: ' + newTitle );
		$status.text( ' Opening the normal editor instead...' );
		window.location.href = mw.util.getUrl( newTitle, { action: 'edit', preload: preloadTitle } );
	}

	function applyExtraFields( rawText, fields ) {
		var text = rawText;
		if ( fields.image ) {
			text = text.replace( /\|image(\s*)=\s*\n/, function ( match, spacing ) {
				return '|image' + spacing + '= ' + safeReplacement( escapeWikitext( fields.image ) ) + '\n';
			} );
		}
		if ( fields.evidence ) {
			text = text.replace( '<Add Picture Here>', safeReplacement( escapeWikitext( fields.evidence ) ) );
		}
		if ( fields.description ) {
			text = text.replace( '<Write Text Here>', safeReplacement( escapeWikitext( fields.description ) ) );
		}
		return text;
	}

	function runInstantCreate( newTitle, preloadTitle, fields, $status ) {
		$status.text( ' Creating...' );
		mw.loader.using( 'mediawiki.api' ).then( function () {
			var api = new mw.Api();
			api.get( {
				action: 'query',
				titles: newTitle,
				formatversion: 2,
				format: 'json'
			} ).then( function ( data ) {
				var pages = ( data.query && data.query.pages ) || [];
				var exists = false;
				pages.forEach( function ( page ) {
					if ( !page.missing ) {
						exists = true;
					}
				} );
				if ( exists ) {
					$status.text( ' Already exists, opening it...' );
					window.location.href = mw.util.getUrl( newTitle );
					return;
				}
				api.get( {
					action: 'query',
					titles: preloadTitle,
					prop: 'revisions',
					rvprop: 'content',
					rvslots: 'main',
					formatversion: 2,
					format: 'json'
				} ).then( function ( tplData ) {
					var tplPages = ( tplData.query && tplData.query.pages ) || [];
					var rawText = '';
					tplPages.forEach( function ( page ) {
						if ( page.revisions && page.revisions[ 0 ] && page.revisions[ 0 ].slots && page.revisions[ 0 ].slots.main ) {
							rawText = page.revisions[ 0 ].slots.main.content;
						}
					} );
					if ( !rawText ) {
						fallbackToManualEditor( newTitle, preloadTitle, $status, 'preload template came back empty' );
						return;
					}
					var finalText = applyExtraFields( stripPreload( rawText ), fields );
					api.postWithToken( 'csrf', {
						action: 'edit',
						title: newTitle,
						text: finalText,
						createonly: true,
						formatversion: 2,
						summary: 'Auto-created'
					} ).then( function ( editResult ) {
						if ( editResult && editResult.edit && editResult.edit.result === 'Success' ) {
							window.location.href = mw.util.getUrl( newTitle );
						} else {
							fallbackToManualEditor( newTitle, preloadTitle, $status, 'edit call did not report Success' );
						}
					}, function ( code ) {
						if ( code === 'articleexists' ) {
							window.location.href = mw.util.getUrl( newTitle );
							return;
						}
						fallbackToManualEditor( newTitle, preloadTitle, $status, 'edit call failed: ' + code );
					} );
				}, function ( code ) {
					fallbackToManualEditor( newTitle, preloadTitle, $status, 'template fetch failed: ' + code );
				} );
			}, function ( code ) {
				fallbackToManualEditor( newTitle, preloadTitle, $status, 'existence check failed: ' + code );
			} );
		} );
	}

	/* ==========================================================
	   Typed-name forms (platforms, countries, cities, people)
	   ========================================================== */
	function buildForm( $container ) {
		if ( !markDone( $container ) ) {
			return;
		}
		var prefix = $container.data( 'prefix' );
		var preloadTitle = $container.data( 'preload' );
		var placeholder = $container.data( 'placeholder' ) || 'Enter a name';
		var buttonLabel = $container.data( 'buttonlabel' ) || 'Add';
		var extraFieldsRaw = $container.data( 'extrafields' ) || '';

		var $input = $( '<input>' ).attr( 'type', 'text' ).attr( 'placeholder', placeholder ).css( 'width', '100%' ).css( 'margin-bottom', '6px' );
		var $wrapper = $( '<div>' ).append( $input );

		var extraInputs = {};
		var plainExtraInputs = [];

		if ( extraFieldsRaw ) {
			extraFieldsRaw.split( ';' ).forEach( function ( pair ) {
				var parts = pair.split( ':' );
				var key = $.trim( parts[ 0 ] );
				var label = $.trim( parts[ 1 ] || key );
				var $extra;
				if ( key === 'description' ) {
					$extra = $( '<textarea>' ).attr( 'placeholder', label ).attr( 'rows', 3 ).css( 'width', '100%' ).css( 'margin-bottom', '6px' );
				} else {
					$extra = $( '<input>' ).attr( 'type', 'text' ).attr( 'placeholder', label ).css( 'width', '100%' ).css( 'margin-bottom', '6px' );
					plainExtraInputs.push( $extra );
				}
				extraInputs[ key ] = $extra;
				$wrapper.append( $( '<div>' ).append( $extra ) );
			} );
		}

		var $button = $( '<button>' ).text( buttonLabel );
		var $status = $( '<span>' ).addClass( 'gar-instant-status' );
		$wrapper.append( $button, ' ', $status );
		$container.empty().append( $wrapper );

		var requiredLetter = bucketLetterFromPrefix( prefix );

		function submit() {
			var name = sanitizeName( $input.val() );
			if ( !name ) {
				mw.notify( 'That name cannot be used. Please enter a plain name.', { type: 'error', tag: 'gar-name' } );
				return;
			}
			if ( requiredLetter && firstLetter( name ) !== requiredLetter ) {
				$status.text( '' );
				mw.notify(
					'"' + name + '" does not start with the letter "' + requiredLetter +
					'". Pick the correct letter, or fix the spelling.',
					{ type: 'error', tag: 'gar-letter' }
				);
				return;
			}
			var fields = {};
			for ( var key in extraInputs ) {
				fields[ key ] = $.trim( extraInputs[ key ].val() );
			}
			runInstantCreate( prefix + name, preloadTitle, fields, $status );
		}

		$button.on( 'click', submit );
		$input.on( 'keydown', function ( e ) {
			if ( e.which === 13 ) {
				e.preventDefault();
				submit();
			}
		} );
		plainExtraInputs.forEach( function ( $extra ) {
			$extra.on( 'keydown', function ( e ) {
				if ( e.which === 13 ) {
					e.preventDefault();
					submit();
				}
			} );
		} );
	}

	/* ==========================================================
	   "Create case" button: auto-numbers Case-0000001, 0000002 ...
	   ========================================================== */
	// Anonymous case: create a case directly under the STREET (no number),
	// with a required reason for withholding the exact address.
	function buildAnonCase( $container ) {
		if ( !markDone( $container ) ) {
			return;
		}
		var parent = $container.data( 'street' );        // e.g. GAR:Offline/C/Canada/T/Toronto/Bloor Street
		var preloadTitle = $container.data( 'preload' );  // Template:Preload/OfflineCaseAnon

		var $intro = $( '<div>' ).css( 'margin-bottom', '6px' ).text(
			'Filing without the street number? Say why the exact address is being ' +
			'withheld.'
		);
		var $reason = $( '<textarea>' ).attr( 'rows', 3 )
			.attr( 'placeholder', 'Why is the exact address being withheld?' )
			.css( { width: '100%', 'margin-bottom': '6px', 'box-sizing': 'border-box' } );
		var $button = $( '<button>' ).text( 'Create anonymous case' );
		var $status = $( '<span>' ).addClass( 'gar-instant-status' );
		$container.empty().append( $intro, $( '<div>' ).append( $reason ),
			$( '<div>' ).append( $button, ' ', $status ) );

		function submit() {
			var reason = $.trim( $reason.val() );
			if ( reason.length < 15 ) {
				mw.notify( 'Give a real reason (at least a sentence) for withholding the address.',
					{ type: 'error', tag: 'gar-anon' } );
				return;
			}
			$button.prop( 'disabled', true );
			$status.text( ' Creating case...' );
			createCaseUnder( parent, preloadTitle, reason, $status, function () {
				$button.prop( 'disabled', false );
			} );
		}
		$button.on( 'click', submit );
	}

	// Shared case-creation core used by both the normal and anonymous buttons.
	// Finds the next Case- number under parent, fills the preload, stamps it
	// pending, optionally injects a withheld-address reason, and opens it.
	function createCaseUnder( parent, preloadTitle, reason, $status, reEnable ) {
		mw.loader.using( 'mediawiki.api' ).then( function () {
			var api = new mw.Api();
			var noNs = stripNs( String( parent ).replace( /_/g, ' ' ) );
			api.get( {
				action: 'query', list: 'allpages', apnamespace: GAR_NS,
				apprefix: noNs + '/Case-', aplimit: 'max', formatversion: 2, format: 'json'
			} ).then( function ( data ) {
				var pages = ( data.query && data.query.allpages ) || [];
				var max = 0;
				pages.forEach( function ( p ) {
					var m = p.title.match( /\/Case-(\d+)$/ );
					if ( m ) { var n = parseInt( m[ 1 ], 10 ); if ( n > max ) { max = n; } }
				} );
				var newTitle = parent + '/Case-' + padCase( max + 1 );
				api.get( {
					action: 'query', titles: preloadTitle, prop: 'revisions',
					rvprop: 'content', rvslots: 'main', formatversion: 2, format: 'json'
				} ).then( function ( tpl ) {
					var tp = ( tpl.query && tpl.query.pages ) || [];
					var raw = '';
					tp.forEach( function ( p ) {
						if ( p.revisions && p.revisions[ 0 ] && p.revisions[ 0 ].slots && p.revisions[ 0 ].slots.main ) {
							raw = p.revisions[ 0 ].slots.main.content;
						}
					} );
					if ( !raw ) {
						$status.text( ' Could not read the case template.' );
						if ( reEnable ) { reEnable(); }
						return;
					}
					var body = stripPreload( raw );
					if ( reason ) {
						body = body.replace( '<GAR-REASON>', safeReplacement( escapeWikitext( reason ) ) );
					}
					body += '\n' + GAR_PENDING_TAG;
					api.postWithToken( 'csrf', {
						action: 'edit', title: newTitle, text: body, createonly: true,
						formatversion: 2, summary: 'Auto-created case'
					} ).then( function () {
						$status.text( ' Created. Opening the case...' );
						window.location.href = mw.util.getUrl( newTitle );
					}, function ( code ) {
						if ( code === 'articleexists' ) {
							window.location.href = mw.util.getUrl( newTitle );
							return;
						}
						$status.text( ' Could not create the case (' + code + ').' );
						if ( reEnable ) { reEnable(); }
					} );
				}, function ( code ) {
					$status.text( ' Could not read the case template (' + code + ').' );
					if ( reEnable ) { reEnable(); }
				} );
			}, function ( code ) {
				$status.text( ' Could not check case numbers (' + code + ').' );
				if ( reEnable ) { reEnable(); }
			} );
		} );
	}

	function buildCreateCase( $container ) {
		if ( !markDone( $container ) ) {
			return;
		}
		var person = $container.data( 'person' );        // e.g. GAR:Aaa/A/Australia/A/Adelaide/Stevie
		var preloadTitle = $container.data( 'preload' );  // Template:Preload/Case

		var $button = $( '<button>' ).text( 'Create case' );
		var $status = $( '<span>' ).addClass( 'gar-instant-status' );
		$container.empty().append( $button, ' ', $status );

		function submit() {
			$button.prop( 'disabled', true );
			$status.text( ' Creating case...' );
			mw.loader.using( 'mediawiki.api' ).then( function () {
				var api = new mw.Api();
				var noNs = stripNs( String( person ).replace( /_/g, ' ' ) );
				// 1. Find the highest existing case number under this person.
				api.get( {
					action: 'query',
					list: 'allpages',
					apnamespace: GAR_NS,
					apprefix: noNs + '/Case-',
					aplimit: 'max',
					formatversion: 2,
					format: 'json'
				} ).then( function ( data ) {
					var pages = ( data.query && data.query.allpages ) || [];
					var max = 0;
					pages.forEach( function ( p ) {
						var m = p.title.match( /\/Case-(\d+)$/ );
						if ( m ) {
							var num = parseInt( m[ 1 ], 10 );
							if ( num > max ) {
								max = num;
							}
						}
					} );
					var caseName = 'Case-' + padCase( max + 1 );
					var newTitle = person + '/' + caseName;
					// 2. Read the case skeleton, unwrap it, create the page.
					api.get( {
						action: 'query',
						titles: preloadTitle,
						prop: 'revisions',
						rvprop: 'content',
						rvslots: 'main',
						formatversion: 2,
						format: 'json'
					} ).then( function ( tpl ) {
						var tp = ( tpl.query && tpl.query.pages ) || [];
						var raw = '';
						tp.forEach( function ( p ) {
							if ( p.revisions && p.revisions[ 0 ] && p.revisions[ 0 ].slots && p.revisions[ 0 ].slots.main ) {
								raw = p.revisions[ 0 ].slots.main.content;
							}
						} );
						if ( !raw ) {
							$status.text( ' Could not read the case template.' );
							$button.prop( 'disabled', false );
							return;
						}
						// Mark it pending so it shows in the review queue. This is
						// only a label -- the case is private either way until it
						// carries the published marker, so nothing depends on it.
						var body = stripPreload( raw ) + '\n' + GAR_PENDING_TAG;
						api.postWithToken( 'csrf', {
							action: 'edit',
							title: newTitle,
							text: body,
							createonly: true,
							formatversion: 2,
							summary: 'Auto-created case'
						} ).then( function () {
							// 3. Go straight into the new case so the contributor
							//    can add the source link to it.
							$status.text( ' Created. Opening the case...' );
							window.location.href = mw.util.getUrl( newTitle );
						}, function ( code ) {
							if ( code === 'articleexists' ) {
								window.location.href = mw.util.getUrl( newTitle );
								return;
							}
							$status.text( ' Could not create the case (' + code + ').' );
							$button.prop( 'disabled', false );
						} );
					}, function ( code ) {
						$status.text( ' Could not read the case template (' + code + ').' );
						$button.prop( 'disabled', false );
					} );
				}, function ( code ) {
					$status.text( ' Could not check case numbers (' + code + ').' );
					$button.prop( 'disabled', false );
				} );
			} );
		}

		$button.on( 'click', submit );
	}

	/* ==========================================================
	   Set the person's photo (shown on the person's own page).
	   Rewrites {{PersonPhoto|...}} on the current page.
	   ========================================================== */
	function readCurrentPage( api ) {
		var pageTitle = mw.config.get( 'wgPageName' );
		return api.get( {
			action: 'query',
			titles: pageTitle,
			prop: 'revisions',
			rvprop: 'content',
			rvslots: 'main',
			formatversion: 2,
			format: 'json'
		} ).then( function ( data ) {
			var pages = ( data.query && data.query.pages ) || [];
			var raw = '';
			pages.forEach( function ( p ) {
				if ( p.revisions && p.revisions[ 0 ] && p.revisions[ 0 ].slots && p.revisions[ 0 ].slots.main ) {
					raw = p.revisions[ 0 ].slots.main.content;
				}
			} );
			return { title: pageTitle, text: raw };
		} );
	}

	// Shared: upload a chosen file, then hand the final filename to onName.
	// Handles the "already uploaded" case by reusing the existing name.
	function uploadThen( file, $status, onName, reEnable ) {
		$status.text( ' Uploading...' );
		mw.loader.using( 'mediawiki.api' ).then( function () {
			var api = new mw.Api();
			api.upload( file, {
				filename: file.name,
				ignorewarnings: true,
				comment: 'Upload for ' + mw.config.get( 'wgPageName' )
			} ).then( function ( result ) {
				var fn = file.name;
				if ( result && result.upload && result.upload.filename ) {
					fn = result.upload.filename;
				}
				$status.text( ' Uploaded. Adding it to the page...' );
				onName( api, fn, $status, reEnable );
			}, function ( code ) {
				// The exact image already exists -> just use that name.
				if ( code === 'fileexists-no-change' || code === 'duplicate' || code === 'exists' ) {
					onName( new mw.Api(), file.name, $status, reEnable );
					return;
				}
				$status.text( ' Upload failed (' + code + '). Choose a file and try again.' );
				if ( reEnable ) { reEnable(); }
			} );
		}, function () {
			$status.text( ' Could not start the upload. Try again.' );
			if ( reEnable ) { reEnable(); }
		} );
	}

	// Upload limits. Shrink target stays safely under $wgMaxImageArea (12.5 MP)
	// so MediaWiki can always build a thumbnail; 5 MB matches $wgMaxUploadSize.
	var GAR_MAX_FILE_MB = 5;
	var GAR_SHRINK_TO_MP = 12;

	// Encode a canvas as JPEG, lowering quality until it fits the size cap.
	function encodeUnderLimit( canvas, quality, cb ) {
		canvas.toBlob( function ( blob ) {
			if ( !blob ) {
				cb( null );
			} else if ( blob.size <= GAR_MAX_FILE_MB * 1048576 || quality <= 0.4 ) {
				cb( blob );
			} else {
				encodeUnderLimit( canvas, quality - 0.15, cb );
			}
		}, 'image/jpeg', quality );
	}

	// Return an upload-ready file. Images within the limits pass through
	// untouched; anything larger (in pixels or bytes) is silently shrunk in
	// the browser first, so an upload is never rejected for being too big.
	function prepareImage( file, cb ) {
		var url = URL.createObjectURL( file );
		var img = new Image();
		img.onload = function () {
			var w = img.naturalWidth, h = img.naturalHeight;
			var mp = ( w * h ) / 1e6;
			if ( mp <= GAR_SHRINK_TO_MP && file.size <= GAR_MAX_FILE_MB * 1048576 ) {
				URL.revokeObjectURL( url );
				cb( null, file );
				return;
			}
			var scale = mp > GAR_SHRINK_TO_MP ? Math.sqrt( GAR_SHRINK_TO_MP / mp ) : 1;
			var canvas = document.createElement( 'canvas' );
			canvas.width = Math.max( 1, Math.round( w * scale ) );
			canvas.height = Math.max( 1, Math.round( h * scale ) );
			canvas.getContext( '2d' ).drawImage( img, 0, 0, canvas.width, canvas.height );
			URL.revokeObjectURL( url );
			encodeUnderLimit( canvas, 0.85, function ( blob ) {
				if ( !blob ) {
					cb( 'Could not process this image. Please try a different one.' );
					return;
				}
				var base = file.name.replace( /\.[^.]+$/, '' );
				cb( null, new File( [ blob ], base + '.jpg', { type: 'image/jpeg' } ) );
			} );
		};
		img.onerror = function () {
			URL.revokeObjectURL( url );
			cb( 'This does not look like a readable image.' );
		};
		img.src = url;
	}

	// Reusable image file-picker + button.
	function makeUploader( buttonLabel, onFile ) {
		var $file = $( '<input>' ).attr( 'type', 'file' )
			.attr( 'accept', 'image/png,image/jpeg,image/webp' )
			.css( 'margin-bottom', '6px' );
		var $btn = $( '<button>' ).text( buttonLabel );
		var $status = $( '<span>' ).addClass( 'gar-instant-status' );
		var $wrap = $( '<div>' ).append(
			$( '<div>' ).append( $file ),
			$( '<div>' ).append( $btn, ' ', $status )
		);
		$btn.on( 'click', function () {
			var file = $file[ 0 ].files && $file[ 0 ].files[ 0 ];
			if ( !file ) {
				mw.notify( 'Choose an image file first.', { type: 'error', tag: 'gar-upl' } );
				return;
			}
			$btn.prop( 'disabled', true );
			$status.text( ' Preparing the image...' );
			prepareImage( file, function ( err, readyFile ) {
				if ( err ) {
					$status.text( ' ' + err );
					$btn.prop( 'disabled', false );
					return;
				}
				onFile( readyFile, $status, function () {
					$btn.prop( 'disabled', false );
				} );
			} );
		} );
		return $wrap;
	}

	// Person page: upload -> set {{PersonPhoto|filename}}, then reload.
	function setPhotoOnPage( api, filename, $status, reEnable ) {
		return readCurrentPage( api ).then( function ( page ) {
			if ( !page.text ) {
				$status.text( ' Could not read this page.' );
				if ( reEnable ) { reEnable(); }
				return;
			}
			var replaced = page.text.replace(
				/\{\{PersonPhoto\s*\|[^}]*\}\}/,
				'{{PersonPhoto|' + escapeWikitext( filename ) + '}}'
			);
			if ( replaced === page.text ) {
				$status.text( ' This page has no photo slot — recreate it from the current template.' );
				if ( reEnable ) { reEnable(); }
				return;
			}
			return api.postWithToken( 'csrf', {
				action: 'edit',
				title: page.title,
				text: replaced,
				formatversion: 2,
				summary: 'Set photo'
			} ).then( function () {
				window.location.reload();
			}, function ( code ) {
				$status.text( ' Save failed (' + code + '). Try again.' );
				if ( reEnable ) { reEnable(); }
			} );
		}, function () {
			$status.text( ' Could not read this page. Try again.' );
			if ( reEnable ) { reEnable(); }
		} );
	}

	// Case page: append a SOURCE LINK to the Evidence area. Nothing is
	// uploaded -- the contributor supplies the address of the source and
	// the operator preserves the archived copy at review.
	function appendSourceLink( url, $status, reEnable ) {
		var END = '<!--GAR-EVIDENCE-END-->';
		mw.loader.using( 'mediawiki.api' ).then( function () {
			var api = new mw.Api();
			readCurrentPage( api ).then( function ( page ) {
				if ( !page.text ) {
					$status.text( ' Could not read this page.' );
					if ( reEnable ) { reEnable(); }
					return;
				}
				if ( page.text.indexOf( END ) === -1 ) {
					$status.text( ' This page has no evidence slot \u2014 recreate it from the current template.' );
					if ( reEnable ) { reEnable(); }
					return;
				}
				// A bare URL only. The visible text IS the destination, so a
				// link can never read as one thing and go somewhere else.
				var safeUrl = url.replace( /[\[\]|<>\s]/g, '' );
				var line = '* ' + safeUrl + '\n';
				var newText = page.text.replace( END, line + END );
				api.postWithToken( 'csrf', {
					action: 'edit',
					title: page.title,
					text: newText,
					formatversion: 2,
					summary: 'Add source link'
				} ).then( function () {
					window.location.reload();
				}, function ( code ) {
					$status.text( ' Save failed (' + code + '). Try again.' );
					if ( reEnable ) { reEnable(); }
				} );
			}, function () {
				$status.text( ' Could not read this page. Try again.' );
				if ( reEnable ) { reEnable(); }
			} );
		} );
	}

	function buildSetPhoto( $container ) {
		if ( !markDone( $container ) ) {
			return;
		}
		$container.empty().append(
			makeUploader( 'Upload photo & show it here', function ( file, $status, reEnable ) {
				uploadThen( file, $status, setPhotoOnPage, reEnable );
			} )
		);
	}

	// Case evidence: the contributor pastes the address of the source.
	function buildAddEvidence( $container ) {
		if ( !markDone( $container ) ) {
			return;
		}
		var $url = $( '<input>' ).attr( 'type', 'url' )
			.attr( 'placeholder', 'Address of the source (https://...)' )
			.css( { width: '100%', 'margin-bottom': '6px', 'box-sizing': 'border-box' } );
		var $btn = $( '<button>' ).text( 'Add source link' );
		var $status = $( '<span>' ).addClass( 'gar-instant-status' );
		$container.empty().append(
			$( '<div>' ).append( $url ),
			$( '<div>' ).append( $btn, ' ', $status )
		);

		function submit() {
			var url = $.trim( $url.val() );
			if ( !/^https?:\/\//i.test( url ) ) {
				mw.notify( 'Enter a full address starting with http:// or https://',
					{ type: 'error', tag: 'gar-link' } );
				return;
			}
			$btn.prop( 'disabled', true );
			$status.text( ' Adding...' );
			appendSourceLink( url, $status, function () {
				$btn.prop( 'disabled', false );
			} );
		}
		$btn.on( 'click', submit );
		$url.on( 'keydown', function ( e ) {
			if ( e.which === 13 ) { e.preventDefault(); submit(); }
		} );
	}

	/* ==========================================================
	   Editable "details" text box on the person page.
	   Rewrites the text between the DESC markers.
	   ========================================================== */
	function buildEditable( $container ) {
		if ( !markDone( $container ) ) {
			return;
		}
		// A page may hold more than one editable box. Each one names its own
		// pair of markers, so they never overwrite each other. Left unset,
		// it edits the DESC region as before.
		var START = '<!--' + ( $container.data( 'start' ) || 'GAR-DESC-START' ) + '-->';
		var END = '<!--' + ( $container.data( 'end' ) || 'GAR-DESC-END' ) + '-->';
		var placeholder = $container.data( 'placeholder' ) ||
			'Add or update the details for this page...';
		var $textarea = $( '<textarea>' ).attr( 'rows', 4 ).css( 'width', '100%' ).css( 'margin-bottom', '6px' ).attr( 'placeholder', placeholder );
		var $button = $( '<button>' ).text( 'Save details' );
		var $status = $( '<span>' ).addClass( 'gar-instant-status' );
		$container.empty().append(
			$( '<div>' ).append( $textarea ),
			$( '<div>' ).append( $button, ' ', $status )
		);

		mw.loader.using( 'mediawiki.api' ).then( function () {
			var api = new mw.Api();
			readCurrentPage( api ).then( function ( page ) {
				var s = page.text.indexOf( START );
				var e = page.text.indexOf( END );
				if ( s > -1 && e > -1 ) {
					$textarea.val( page.text.substring( s + START.length, e ) );
				}
			} );

			function submit() {
				var val = $textarea.val();
				$status.text( ' Saving...' );
				readCurrentPage( api ).then( function ( page ) {
					var s = page.text.indexOf( START );
					var e = page.text.indexOf( END );
					if ( s < 0 || e < 0 ) {
						$status.text( ' Could not find the details area on this page.' );
						return;
					}
					var newRaw = page.text.substring( 0, s + START.length ) + escapeWikitext( val ) + page.text.substring( e );
					api.postWithToken( 'csrf', {
						action: 'edit',
						title: page.title,
						text: newRaw,
						formatversion: 2,
						summary: 'Update details'
					} ).then( function () {
						window.location.reload();
					}, function ( code ) {
						$status.text( ' Save failed (' + code + ').' );
					} );
				} );
			}

			$button.on( 'click', submit );
		} );
	}

	/* ==========================================================
	   A-Z letter grid. Batch-checks which letters already exist,
	   turns those into ordinary links (no "Creating"), and leaves
	   only genuinely-new letters as clickable "add" links.
	   ========================================================== */
	function createLetter( $el ) {
		var title = $el.data( 'title' );
		var preload = $el.data( 'preload' );
		var $status = $( '<span>' ).addClass( 'gar-instant-status' );
		$el.after( $status );
		$el.off( 'click' );
		$el.css( 'cursor', 'default' );
		runInstantCreate( title, preload, {}, $status );
	}

	function processLetterGrid( $content ) {
		var $letters = $content.find( '.gar-instant-letter' ).filter( function () {
			return !$( this ).data( 'garDone' );
		} );
		if ( !$letters.length ) {
			return;
		}
		$letters.each( function () {
			$( this ).data( 'garDone', true );
		} );

		var titles = [];
		$letters.each( function () {
			titles.push( String( $( this ).data( 'title' ) ) );
		} );
		var uniq = titles.filter( function ( t, i ) {
			return titles.indexOf( t ) === i;
		} );

		mw.loader.using( 'mediawiki.api' ).then( function () {
			var api = new mw.Api();
			var existsMap = {};
			var chunks = [];
			var i;
			for ( i = 0; i < uniq.length; i += 50 ) {
				chunks.push( uniq.slice( i, i + 50 ) );
			}
			var seq = Promise.resolve();
			chunks.forEach( function ( chunk ) {
				seq = seq.then( function () {
					return api.get( {
						action: 'query',
						titles: chunk.join( '|' ),
						formatversion: 2,
						format: 'json'
					} ).then( function ( data ) {
						var pages = ( data.query && data.query.pages ) || [];
						pages.forEach( function ( p ) {
							existsMap[ p.title.replace( /_/g, ' ' ) ] = !p.missing;
						} );
					} );
				} );
			} );
			seq.then( function () {
				$letters.each( function () {
					var $el = $( this );
					var title = String( $el.data( 'title' ) ).replace( /_/g, ' ' );
					if ( existsMap[ title ] ) {
						var $link = $( '<a>' )
							.attr( 'href', mw.util.getUrl( $el.data( 'title' ) ) )
							.addClass( 'gar-letter-exists' )
							.text( $el.text() );
						$el.replaceWith( $link );
					} else {
						$el.addClass( 'gar-letter-new' );
						$el.on( 'click', function () {
							createLetter( $el );
						} );
					}
				} );
			} );
		} );
	}

	/* ==========================================================
	   Reviewer tools
	   ========================================================== */

	// On a pending case page, a reviewer gets a one-press approve bar.
	// A contributor looking at their own pending case: tell them plainly that
	// it is saved and waiting, and that only the operator can publish it.
	function maybeShowPendingBar( $content ) {
		if ( garIsReviewer() || !garOnCasePage() || garIsPublished() ) {
			return;
		}
		if ( $( '#gar-pending-bar' ).length ) {
			return;
		}
		$content.prepend(
			$( '<div>' ).attr( 'id', 'gar-pending-bar' ).addClass( 'gar-pending-bar' ).append(
				$( '<div>' ).text( 'Saved and sent to the operator for review.' ),
				$( '<div>' ).addClass( 'gar-pending-sub' ).text(
					'This case is not visible to the public yet. Add the link to your source below — ' +
					'you can keep working on it until it is published. Once the operator publishes it, ' +
					'it becomes public and can no longer be changed here.'
				)
			)
		);
	}

	function maybeShowApproveBar( $content ) {
		if ( !garIsReviewer() || !garOnCasePage() || garIsPublished() ) {
			return;
		}
		if ( $( '#gar-approve-bar' ).length ) {
			return;
		}
		var $btn = $( '<button>' ).text( 'Approve & publish this case' );
		var $status = $( '<span>' ).addClass( 'gar-instant-status' );
		var $bar = $( '<div>' ).attr( 'id', 'gar-approve-bar' ).addClass( 'gar-approve-bar' ).append(
			$( '<div>' ).text( 'Awaiting your review. This case is not visible to the public.' ),
			$( '<div>' ).append( $btn, ' ', $status )
		);
		$content.prepend( $bar );

		$btn.on( 'click', function () {
			$btn.prop( 'disabled', true );
			$status.text( ' Publishing...' );
			mw.loader.using( 'mediawiki.api' ).then( function () {
				var api = new mw.Api();
				readCurrentPage( api ).then( function ( page ) {
					if ( !page.text ) {
						$status.text( ' Could not read this page.' );
						$btn.prop( 'disabled', false );
						return;
					}
					// Drop the queue label, add the marker that makes it public.
					var cleaned = page.text.replace(
						/\n?\[\[\s*Category\s*:\s*Pending cases\s*\]\]/gi, ''
					);
					if ( !/\[\[\s*Category\s*:\s*Published cases\s*\]\]/i.test( cleaned ) ) {
						cleaned = cleaned.replace( /\s*$/, '' ) + '\n' + GAR_PUBLISHED_TAG;
					}
					if ( cleaned === page.text ) {
						$status.text( ' This case is already published.' );
						$btn.prop( 'disabled', false );
						return;
					}
					api.postWithToken( 'csrf', {
						action: 'edit',
						title: page.title,
						text: cleaned,
						formatversion: 2,
						summary: 'Approved: published'
					} ).then( function () {
						window.location.reload();
					}, function ( code ) {
						$status.text( ' Could not publish (' + code + ').' );
						$btn.prop( 'disabled', false );
					} );
				}, function () {
					$status.text( ' Could not read this page.' );
					$btn.prop( 'disabled', false );
				} );
			} );
		} );
	}

	// Standing reminder for reviewers: how many cases are waiting.
	// Standing reminder for reviewers. Counts every case that is NOT published
	// yet, rather than trusting the "Pending cases" label -- so a case still
	// reaches your queue even if the label was never applied.
	// Standing reminder for reviewers, drawn as a banner at the top of the page
	// so it cannot be missed or auto-dismissed. Counts every case that is NOT
	// published yet rather than trusting the "Pending cases" label, so a case
	// reaches the queue even if the label was never applied. Any failure is
	// reported in the banner instead of disappearing silently.
	function garPendingNotice( $content ) {
		if ( !garIsReviewer() || $( '#gar-review-bar' ).length ) {
			return;
		}
		var $bar = $( '<div>' ).attr( 'id', 'gar-review-bar' ).addClass( 'gar-review-bar' );

		function fail( why ) {
			$bar.empty().text( 'Could not check the review queue (' + why + ').' );
			if ( !$bar.parent().length ) {
				$content.prepend( $bar );
			}
		}

		mw.loader.using( [ 'mediawiki.api', 'mediawiki.util' ] ).then( function () {
			var api = new mw.Api();
			api.get( {
				action: 'query',
				list: 'allpages',
				apnamespace: GAR_NS,
				aplimit: 'max',
				formatversion: 2,
				format: 'json'
			} ).then( function ( a ) {
				var cases = ( ( a.query && a.query.allpages ) || [] )
					.map( function ( p ) { return p.title; } )
					.filter( garTitleNeedsReview );
				if ( !cases.length ) {
					return;
				}
				api.get( {
					action: 'query',
					list: 'categorymembers',
					cmtitle: 'Category:' + GAR_PUBLISHED_CAT,
					cmnamespace: GAR_NS,
					cmlimit: 'max',
					formatversion: 2,
					format: 'json'
				} ).then( function ( d ) {
					var published = {};
					( ( d.query && d.query.categorymembers ) || [] ).forEach( function ( p ) {
						published[ p.title ] = true;
					} );
					var waiting = cases.filter( function ( t ) { return !published[ t ]; } );
					if ( !waiting.length ) {
						return;
					}
					$bar.append(
						$( '<strong>' ).text(
							waiting.length +
							( waiting.length === 1 ? ' case is' : ' cases are' ) +
							' awaiting your review.'
						),
						' '
					);
					waiting.slice( 0, 5 ).forEach( function ( t ) {
						$bar.append(
							$( '<div>' ).append(
								$( '<a>' ).attr( 'href', mw.util.getUrl( t ) ).text( t )
							)
						);
					} );
					if ( waiting.length > 5 ) {
						$bar.append( $( '<div>' ).text(
							'…and ' + ( waiting.length - 5 ) + ' more.'
						) );
					}
					$content.prepend( $bar );
				}, function ( code ) {
					fail( code );
				} );
			}, function ( code ) {
				fail( code );
			} );
		}, function () {
			fail( 'scripts did not load' );
		} );
	}

	/* ==========================================================
	   Wire everything up
	   ========================================================== */
	mw.hook( 'wikipage.content' ).add( function ( $content ) {
		$content.find( '.gar-auto-grid' ).each( function () {
			renderAutoGrid( $( this ) );
		} );
		$content.find( '.gar-instant-create' ).each( function () {
			buildForm( $( this ) );
		} );
		$content.find( '.gar-create-case' ).each( function () {
			buildCreateCase( $( this ) );
		} );
		$content.find( '.gar-anon-case' ).each( function () {
			buildAnonCase( $( this ) );
		} );
		$content.find( '.gar-set-photo' ).each( function () {
			buildSetPhoto( $( this ) );
		} );
		$content.find( '.gar-add-evidence, .gar-add-image' ).each( function () {
			buildAddEvidence( $( this ) );
		} );
		$content.find( '.gar-editable' ).each( function () {
			buildEditable( $( this ) );
		} );
		processLetterGrid( $content );
		maybeShowApproveBar( $content );
		maybeShowPendingBar( $content );
		garPendingNotice( $content );
	} );

}() );