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

MediaWiki interface page
Revision as of 17:12, 20 July 2026 by Palindromayd (talk | contribs)

Note: After publishing, you may have to bypass your browser's cache to see the changes.

  • Firefox / Safari: Hold Shift while clicking Reload, or press either Ctrl-F5 or Ctrl-R (⌘-R on a Mac)
  • Google Chrome: Press Ctrl-Shift-R (⌘-Shift-R on a Mac)
  • Edge: Hold Ctrl while clicking Refresh, or press Ctrl-F5.
( 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]]';

	// 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, '{' )
			.replace( /\}/g, '}' )
			.replace( /\[/g, '[' )
			.replace( /\]/g, ']' )
			.replace( /\|/g, '|' )
			.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 ).replace( /\s+/g, ' ' ).trim();
		if ( !n ) {
			return null;
		}
		if ( /[#<>\[\]{}|/]/.test( n ) ) {
			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 };

	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( 'Please use a plain name without the characters / # < > [ ] { } |', { 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 ...
	   ========================================================== */
	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;
						}
						// A contributor's case is stamped pending; a reviewer's
						// case is public straight away.
						var body = stripPreload( raw );
						if ( !garIsReviewer() ) {
							body += '\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, label, $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;
				}
				var safeUrl = url.replace( /[\[\]|<>\s]/g, '' );
				var safeLabel = escapeWikitext( label || safeUrl );
				var line = '* [' + safeUrl + ' ' + safeLabel + ']\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 $label = $( '<input>' ).attr( 'type', 'text' )
			.attr( 'placeholder', 'Short description of what this shows (optional)' )
			.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( $label ),
			$( '<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, $.trim( $label.val() ), $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;
		}
		var START = '<!--GAR-DESC-START-->';
		var END = '<!--GAR-DESC-END-->';
		var $textarea = $( '<textarea>' ).attr( 'rows', 4 ).css( 'width', '100%' ).css( 'margin-bottom', '6px' ).attr( 'placeholder', 'Add or update details about this person...' );
		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() ) {
			return;
		}
		var cats = mw.config.get( 'wgCategories' ) || [];
		if ( cats.indexOf( GAR_PENDING_CAT ) === -1 ) {
			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() ) {
			return;
		}
		var cats = mw.config.get( 'wgCategories' ) || [];
		if ( cats.indexOf( GAR_PENDING_CAT ) === -1 ) {
			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;
					}
					var cleaned = page.text.replace(
						/\n?\[\[\s*Category\s*:\s*Pending cases\s*\]\]/gi, ''
					);
					if ( cleaned === page.text ) {
						$status.text( ' Nothing to approve on this page.' );
						$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.
	function garPendingNotice() {
		if ( !garIsReviewer() ) {
			return;
		}
		mw.loader.using( [ 'mediawiki.api', 'mediawiki.util' ] ).then( function () {
			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 n = ( ( d.query && d.query.categorymembers ) || [] ).length;
				if ( !n ) {
					return;
				}
				var $msg = $( '<span>' ).append(
					document.createTextNode(
						n + ( n === 1 ? ' case is' : ' cases are' ) + ' awaiting your review. '
					),
					$( '<a>' ).attr( 'href', mw.util.getUrl( 'Category:' + GAR_PENDING_CAT ) )
						.text( 'Review them' )
				);
				mw.notify( $msg, { autoHide: false, tag: 'gar-pending', type: 'warn' } );
			} );
		} );
	}

	/* ==========================================================
	   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-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 );
}() );