Jump to content

User:Swatjester/Adjutant.js

From Wikipedia, the free encyclopedia
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.
/**
 * Adjutant - WikiProject Military history (MILHIST) coordinator clerking helper
 * Shortcut: [[WP:ADJUTANT]]
 *
 * WHAT THIS IS
 *   A standalone English Wikipedia userscript that accelerates routine MILHIST
 *   assessment-queue clerking by adding one-click controls to the pages where
 *   coordinators already work. This is ASSISTED EDITING, not a bot: every edit
 *   is made through the logged-in coordinator's own account, with a human in the
 *   loop (diff preview + confirm before every save). There is no bot account and
 *   no BRFA - this stays firmly within assisted-editing norms.
 *
 * HARD CONSTRAINTS (see README for the full list)
 *   - Standalone. No dependency on any other userscript.
 *   - No AI/LLM features and no third-party network I/O of any kind.
 *   - CSP-safe: all network I/O goes through mw.Api (the Action API). UI is built
 *     from DOM/OOUI nodes, never via innerHTML or string-injected markup. (The one
 *     exception is the diff table returned by action=compare, which is trusted
 *     wiki HTML inserted via jQuery's $.parseHTML - it executes no scripts.)
 *   - Runs as the logged-in user; no-ops when logged out or off-page.
 *   - Full cross-skin support: anchors DOM work to skin-independent containers
 *     (.mw-parser-output / #mw-content-text) and adds entry points via
 *     mw.util.addPortletLink so Monobook, Vector legacy and Vector 2022 all work.
 *
 * INSTALL
 *   Add to Special:MyPage/common.js:
 *     mw.loader.load('https://en.wikipedia.org/wiki/User:Example/Adjutant.js?action=raw&ctype=text/javascript');
 *   or paste the contents directly.
 *
 * CONFIG / LOG
 *   Two portlet links are added: "Adjutant settings" (OOUI settings dialog +
 *   action log with per-edit undo) and "Adjutant: dry-run ON/OFF" (toggle).
 *
 * PER-PHASE CONTROLS
 *   1. Wikipedia:WikiProject Military history/Assessment - Assess B/C/Start +
 *      Decline buttons on each signed request (B opens a B1-B5 checklist popup).
 *   2. Wikipedia talk:WikiProject Military history/Coordinators (AutoCheck report)
 *      - Confirm / Downgrade buttons per listed article.
 *   3. Wikipedia talk:WikiProject Military history - per-thread "Move" control
 *      (cross-page cut-and-paste to a configurable venue).
 *   4. A-Class review reminder - one button to post the stale-review boilerplate.
 *   5. Passive read-only sanity flags + backlog links (modular; easy to disable).
 *
 * @license CC-BY-SA-4.0 / GFDL (English Wikipedia content licensing)
 */
/* global mw, OO, $ */
( function () {
	'use strict';

	// ---- Init guard -------------------------------------------------------
	window.Adjutant = window.Adjutant || {};
	var Adjutant = window.Adjutant;
	if ( Adjutant.loaded ) {
		return;
	}
	Adjutant.loaded = true;
	Adjutant.VERSION = '0.1.18';
	Adjutant.SHORTCUT = '[[WP:ADJUTANT|Adjutant]]';

	// ResourceLoader dependencies must be present before we touch any of this.
	// Keep this list tight: any unregistered module name rejects the whole
	// using() promise and would silently abort the entire script. We use OOUI,
	// mediawiki.api and mediawiki.util only - no jQuery UI.
	mw.loader.using( [
		'mediawiki.api',
		'mediawiki.util',
		'oojs-ui-core',
		'oojs-ui-windows',
		'oojs-ui-widgets'
	] ).then( function () {
		// Defer to DOM ready so portlet containers and content exist before we
		// add links / scan entries (the using() promise can resolve early when
		// the modules are already cached).
		$( init );
	}, function ( err ) {
		mw.log.error( '[Adjutant] failed to load dependencies', err );
	} );

	// =======================================================================
	//  Bootstrap / activation gating
	// =======================================================================
	// Build the 4-tilde signature at runtime. IMPORTANT: never write the
	// literal token in this file's source. MediaWiki runs a pre-save transform
	// over .js user-page source on save, expanding signatures and
	// template-substitution (subst:) calls even inside string literals and
	// comments -- which corrupts the script (an expanded multiline signature
	// breaks the surrounding JS string). Splitting the token keeps it out of
	// the source while still emitting the real token at runtime, where the
	// server expands it correctly on the script's edits. Do not "simplify"
	// this back to a literal. (For the same reason this comment avoids writing
	// the literal tokens it describes.)
	function wikiSignature() {
		return '~~' + '~~';
	}

	function init() {
		// Assisted editing only: bail out cleanly if the user is logged out.
		if ( mw.config.get( 'wgUserName' ) === null ) {
			mw.log( '[Adjutant] not logged in - no-op.' );
			return;
		}
		mw.log( '[Adjutant] v' + Adjutant.VERSION + ' loaded on ' + mw.config.get( 'wgPageName' ) );

		Config.load();

		// Settings + dry-run portlet links are useful everywhere.
		addPortletLinks();

		// Phase dispatch by exact page. wgPageName uses underscores for spaces.
		var page = mw.config.get( 'wgPageName' );
		var byPage = {
			'Wikipedia:WikiProject_Military_history/Assessment': Phase1,
			'Wikipedia:WikiProject_Military_history/Assessment/Requests': Phase1,
			'Wikipedia_talk:WikiProject_Military_history/Coordinators': Phase2,
			'Wikipedia_talk:WikiProject_Military_history': Phase3
		};

		function runPagePhase() {
			try {
				if ( byPage[ page ] ) {
					byPage[ page ].run();
				}
			} catch ( e ) {
				mw.log.error( '[Adjutant] page phase error', e );
			}
		}

		runPagePhase();
		mw.hook( 'wikipage.content' ).add( runPagePhase );
		setTimeout( runPagePhase, 500 );
		setTimeout( runPagePhase, 1500 );

		try {
			// Phase 4 activates on A-Class review subpages and the project talk page.
			if ( Phase4.appliesTo( page ) ) {
				Phase4.run();
			}
			// Phase 5 sanity checks run on any talk page carrying a MILHIST banner.
			if ( Config.get( 'sanityChecks' ) && Phase5.appliesTo() ) {
				Phase5.run();
			}
		} catch ( e ) {
			mw.log.error( '[Adjutant] phase dispatch error', e );
		}
	}

	// =======================================================================
	//  Configuration (persisted via mw.user.options, localStorage fallback)
	// =======================================================================
	var Config = ( function () {
		var OPTION_KEY = 'userjs-adjutant';
		var LS_KEY = 'adjutant-config';
		var defaults = {
			// Edit-summary wording. The script name/shortcut is always added.
			summarySuffix: '(MILHIST coordinator clerking)',
			// Behaviour when the MILHIST banner is missing on a talk page.
			//   'add'  - add the banner with the chosen class + params (default)
			//   'skip' - refuse to assess and warn
			missingBanner: 'add',
			// Editable destination list for the Phase 3 move tool.
			moveDestinations: [
				'Wikipedia talk:WikiProject Military history/Requests for project input',
				'Wikipedia:WikiProject Military history/Assessment',
				'Wikipedia:WikiProject Military history/Review',
				'Wikipedia:WikiProject Military history/Peer review'
			],
			// Serialise saves with at least this delay (ms) between writes.
			throttleDelay: 2000,
			// Dry-run: run everything (incl. diff preview) but suppress the save.
			dryRun: false,
			// Phase 5 passive sanity checks on/off.
			sanityChecks: true
		};
		var current = $.extend( {}, defaults );

		function load() {
			var raw = null;
			try {
				raw = mw.user.options.get( OPTION_KEY );
			} catch ( e ) { /* ignore */ }
			if ( !raw ) {
				try {
					raw = window.localStorage.getItem( LS_KEY );
				} catch ( e2 ) { /* ignore */ }
			}
			if ( raw ) {
				try {
					current = $.extend( {}, defaults, JSON.parse( raw ) );
				} catch ( e3 ) {
					mw.log.warn( '[Adjutant] could not parse saved config; using defaults.' );
				}
			}
		}

		function save() {
			var json = JSON.stringify( current );
			// Persist to user options (cross-device); fall back to localStorage.
			try {
				new mw.Api().saveOption( OPTION_KEY, json ).fail( function () {
					try {
						window.localStorage.setItem( LS_KEY, json );
					} catch ( e ) { /* ignore */ }
				} );
			} catch ( e ) {
				try {
					window.localStorage.setItem( LS_KEY, json );
				} catch ( e2 ) { /* ignore */ }
			}
		}

		return {
			load: load,
			save: save,
			get: function ( k ) { return current[ k ]; },
			set: function ( k, v ) { current[ k ] = v; save(); },
			all: function () { return $.extend( {}, current ); },
			defaults: defaults
		};
	}() );

	// =======================================================================
	//  Action log + per-edit undo
	// =======================================================================
	var ActionLog = ( function () {
		var entries = []; // {page, summary, revid, undone, dryRun, ts}

		function add( entry ) {
			entry.ts = new Date();
			entries.push( entry );
			return entry;
		}

		function undo( entry ) {
			if ( entry.undone || entry.dryRun || !entry.revid ) {
				return $.Deferred().reject( 'cannot-undo' ).promise();
			}
			return Api.undo( entry.page, entry.revid ).then( function ( res ) {
				entry.undone = true;
				return res;
			} );
		}

		return { add: add, undo: undo, list: function () { return entries.slice(); } };
	}() );

	// =======================================================================
	//  API layer - reads, diff preview, throttled conflict-safe writes
	// =======================================================================
	var Api = ( function () {
		var api = null;
		function get() {
			if ( !api ) {
				api = new mw.Api();
			}
			return api;
		}

		// --- Throttle: serialise all saves with a configurable gap. ---------
		var queue = $.Deferred().resolve().promise();
		function delay( ms ) {
			var d = $.Deferred();
			setTimeout( function () { d.resolve(); }, ms );
			return d.promise();
		}
		function throttled( fn ) {
			var run = queue.then( fn, fn );
			// Always wait before the next save regardless of outcome.
			queue = run.then(
				function () { return delay( Config.get( 'throttleDelay' ) ); },
				function () { return delay( Config.get( 'throttleDelay' ) ); }
			);
			return run;
		}

		// --- Read current wikitext + timestamps for conflict detection. -----
		function readPage( title ) {
			return get().get( {
				action: 'query',
				prop: 'revisions',
				rvslots: 'main',
				rvprop: 'content|timestamp|ids',
				titles: title,
				formatversion: 2,
				curtimestamp: 1
			} ).then( function ( data ) {
				var pages = data.query && data.query.pages;
				var p = pages && pages[ 0 ];
				if ( !p ) {
					return $.Deferred().reject( 'no-page-data' );
				}
				if ( p.missing ) {
					return {
						title: title,
						missing: true,
						content: '',
						basetimestamp: undefined,
						starttimestamp: data.curtimestamp,
						revid: undefined
					};
				}
				var rev = p.revisions[ 0 ];
				return {
					title: title,
					missing: false,
					content: rev.slots.main.content,
					basetimestamp: rev.timestamp,
					starttimestamp: data.curtimestamp,
					revid: rev.revid
				};
			} );
		}

		// --- Map a section heading -> its 0-based edit index (action=parse). -
		function sectionIndex( title, headingText ) {
			return get().get( {
				action: 'parse', page: title, prop: 'sections', formatversion: 2
			} ).then( function ( data ) {
				var secs = ( data.parse && data.parse.sections ) || [];
				var want = normaliseHeading( headingText );
				for ( var i = 0; i < secs.length; i++ ) {
					if ( normaliseHeading( secs[ i ].line ) === want ) {
						return secs[ i ].index; // string, e.g. "3"
					}
				}
				return null;
			} );
		}
		function normaliseHeading( h ) {
			return $( '<div>' ).text( String( h ) ).text().replace( /\s+/g, ' ' ).trim().toLowerCase();
		}

		// --- Diff preview via action=compare (trusted wiki HTML). -----------
		function compare( title, fromText, toText ) {
			return get().get( {
				action: 'compare',
				fromtitle: title,
				totitle: title,
				fromtext: fromText,
				totext: toText,
				prop: 'diff',
				formatversion: 2
			} ).then( function ( data ) {
				return ( data.compare && ( data.compare.body || data.compare[ '*' ] ) ) || '';
			} );
		}

		// --- Save: conflict-safe, throttled, dry-run aware, logged. ---------
		// opts: { title, summary, build(content)->newText|false, section? }
		// `build` is re-run against freshly read content on every attempt so
		// edit conflicts are re-applied rather than blind-overwritten.
		function save( opts ) {
			return throttled( function () { return attempt( opts, 0 ); } );
		}
		function attempt( opts, n ) {
			return readPage( opts.title ).then( function ( page ) {
				var newText = opts.build( page.content, page );
				if ( newText === false || newText === null || newText === undefined ) {
					return $.Deferred().reject( 'build-failed' );
				}
				if ( newText === page.content ) {
					return $.Deferred().reject( 'no-change' );
				}
				var summary = decorateSummary( opts.summary );
				if ( Config.get( 'dryRun' ) ) {
					var entry = ActionLog.add( {
						page: opts.title, summary: summary, revid: null, dryRun: true
					} );
					mw.log( '[Adjutant] DRY-RUN - would save', opts.title, summary );
					return { dryRun: true, entry: entry };
				}
				var params = {
					action: 'edit',
					title: opts.title,
					text: newText,
					summary: summary,
					basetimestamp: page.basetimestamp,
					starttimestamp: page.starttimestamp,
					formatversion: 2
				};
				return get().postWithEditToken( params ).then( function ( res ) {
					var revid = res.edit && res.edit.newrevid;
					var logEntry = ActionLog.add( {
						page: opts.title, summary: summary, revid: revid, dryRun: false
					} );
					return { edit: res.edit, entry: logEntry };
				}, function ( code, info ) {
					if ( code === 'editconflict' && n < 2 ) {
						mw.log.warn( '[Adjutant] edit conflict on', opts.title, '- retrying.' );
						return attempt( opts, n + 1 );
					}
					return $.Deferred().reject( code, info );
				} );
			} );
		}

		function undo( title, revid ) {
			return throttled( function () {
				return get().postWithEditToken( {
					action: 'edit',
					title: title,
					undo: revid,
					summary: decorateSummary( 'Undo previous Adjutant edit' ),
					formatversion: 2
				} );
			} );
		}

		function decorateSummary( s ) {
			return s + ' via ' + Adjutant.SHORTCUT + ' ' + Config.get( 'summarySuffix' );
		}

		return {
			raw: get,
			readPage: readPage,
			sectionIndex: sectionIndex,
			compare: compare,
			save: save,
			undo: undo
		};
	}() );

	// =======================================================================
	//  Wikitext utilities - template scanning, line/section matching
	// =======================================================================
	var WT = ( function () {

		function escapeRegex( s ) {
			return s.replace( /[.*+?^${}()|[\]\\]/g, '\\$&' );
		}

		// Normalise a template/page name: first-letter case-insensitive,
		// spaces and underscores equivalent, collapse whitespace.
		function normName( name ) {
			name = String( name ).replace( /_/g, ' ' ).replace( /\s+/g, ' ' ).trim();
			return name.charAt( 0 ).toUpperCase() + name.slice( 1 );
		}

		// Find a template invocation by any of its accepted names. Returns the
		// FIRST match as { start, end, text, inner } (inner excludes the
		// surrounding {{ }}), scanning with brace-depth tracking so nested
		// templates and [[links]] don't confuse the boundaries. Returns null
		// if not found.
		function findTemplate( wikitext, names ) {
			var wanted = {};
			names.forEach( function ( n ) { wanted[ normName( n ) ] = true; } );
			var i = 0;
			while ( i < wikitext.length ) {
				if ( wikitext.charAt( i ) === '{' && wikitext.charAt( i + 1 ) === '{' ) {
					var end = matchBraces( wikitext, i );
					if ( end === -1 ) { return null; }
					var inner = wikitext.slice( i + 2, end - 2 );
					var name = inner.split( /[|}]/ )[ 0 ].split( /[\n]/ )[ 0 ];
					if ( wanted[ normName( name ) ] ) {
						return { start: i, end: end, text: wikitext.slice( i, end ), inner: inner };
					}
					// Not the template we want: descend to find nested ones.
					var nested = findTemplate( inner, names );
					if ( nested ) {
						return {
							start: i + 2 + nested.start,
							end: i + 2 + nested.end,
							text: nested.text,
							inner: nested.inner
						};
					}
					i = end;
				} else {
					i++;
				}
			}
			return null;
		}

		// Given index of "{{", return index just past the matching "}}".
		function matchBraces( s, start ) {
			var depth = 0, i = start;
			while ( i < s.length ) {
				if ( s.charAt( i ) === '{' && s.charAt( i + 1 ) === '{' ) {
					depth++; i += 2; continue;
				}
				if ( s.charAt( i ) === '}' && s.charAt( i + 1 ) === '}' ) {
					depth--; i += 2;
					if ( depth === 0 ) { return i; }
					continue;
				}
				i++;
			}
			return -1;
		}

		// Split a template's inner text into top-level parameters (ignoring
		// separators inside nested {{ }}, [[ ]] and {{{ }}}). The first chunk
		// is the template name. Returns array of raw param strings.
		function splitParams( inner ) {
			var parts = [];
			var depthCurly = 0, depthSquare = 0, buf = '', i = 0;
			while ( i < inner.length ) {
				var two = inner.substr( i, 2 );
				if ( two === '{{' || two === '[[' ) {
					if ( two === '{{' ) { depthCurly++; } else { depthSquare++; }
					buf += two; i += 2; continue;
				}
				if ( two === '}}' || two === ']]' ) {
					if ( two === '}}' ) { depthCurly = Math.max( 0, depthCurly - 1 ); } else { depthSquare = Math.max( 0, depthSquare - 1 ); }
					buf += two; i += 2; continue;
				}
				if ( inner.charAt( i ) === '|' && depthCurly === 0 && depthSquare === 0 ) {
					parts.push( buf ); buf = ''; i++; continue;
				}
				buf += inner.charAt( i ); i++;
			}
			parts.push( buf );
			return parts;
		}

		// --- Line matcher: rendered entry -> source line(s). ----------------
		// Locate the unique wikitext line for an entry by combining the article
		// title wikilink and the signature timestamp. Returns
		// { lines, startLine, endLine } or null (logging an error) if there is
		// no unique match. Survives reordering/re-rendering; never uses DOM
		// offsets.
		function matchEntryLine( wikitext, criteria ) {
			var lines = wikitext.split( '\n' );
			var ts = criteria.timestamp;
			var candidates = [];
			for ( var i = 0; i < lines.length; i++ ) {
				if ( ts ? lines[ i ].indexOf( ts ) !== -1 :
					( criteria.title && articleLinkRegex( criteria.title ).test( lines[ i ] ) ) ) {
					candidates.push( i );
				}
			}
			if ( candidates.length === 0 ) {
				mw.log.error( '[Adjutant] no wikitext line matched entry', criteria );
				return null;
			}
			if ( candidates.length > 1 && criteria.title ) {
				var linkRe = articleLinkRegex( criteria.title );
				candidates = candidates.filter( function ( idx ) {
					return linkRe.test( lines[ idx ] );
				} );
			}
			if ( candidates.length !== 1 ) {
				mw.log.error( '[Adjutant] could not uniquely match entry', criteria );
				return null;
			}
			return { lineIndex: candidates[ 0 ], line: lines[ candidates[ 0 ] ], lines: lines };
		}

		function articleLinkRegex( title ) {
			var t = escapeRegex( normName( title ) ).replace( /\\?[ _]/g, '[ _]+' );
			// First letter case-insensitive; allow [[Title]] or [[Title|...]] etc.
			return new RegExp( '\\[\\[\\s*' + t + '\\s*(\\||\\]\\]|#)', 'i' );
		}

		function articleLinkMatchRegex( title ) {
			var t = escapeRegex( normName( title ) ).replace( /\\?[ _]/g, '[ _]+' );
			return new RegExp( '(\\[\\[\\s*' + t + '(?:\\s*(?:#[^|\\]]*)?)?(?:\\|[^\\]]*)?\\]\\])', 'i' );
		}

		// --- Section extraction by heading text. ----------------------------
		// Returns { start, end, level, text } for the section spanning from its
		// "== heading ==" line up to the next heading of equal/higher level
		// (or end of page). Null if the heading is not found.
		function findSection( wikitext, headingText ) {
			var re = /^(={2,6})\s*(.+?)\s*\1\s*$/gm;
			var m, target = null, want = normaliseSectionHeading( headingText );
			var headings = [];
			while ( ( m = re.exec( wikitext ) ) !== null ) {
				headings.push( { index: m.index, length: m[ 0 ].length, level: m[ 1 ].length, title: m[ 2 ] } );
			}
			for ( var i = 0; i < headings.length; i++ ) {
				if ( normaliseSectionHeading( headings[ i ].title ) === want ) { target = i; break; }
			}
			if ( target === null ) { return null; }
			var h = headings[ target ];
			var end = wikitext.length;
			for ( var j = target + 1; j < headings.length; j++ ) {
				if ( headings[ j ].level <= h.level ) { end = headings[ j ].index; break; }
			}
			return { start: h.index, end: end, level: h.level, text: wikitext.slice( h.index, end ) };
		}

		function normaliseSectionHeading( heading ) {
			var text = String( heading )
				.replace( /<!--[\s\S]*?-->/g, '' )
				.replace( /<[^>]+>/g, '' )
				.replace( /\[\[\s*:?([^|\]#]+)(?:#[^|\]]*)?\|([^\]]+)\]\]/g, '$2' )
				.replace( /\[\[\s*:?([^\]]+)\]\]/g, '$1' )
				.replace( /\[https?:\/\/[^\s\]]+\s+([^\]]+)\]/gi, '$1' )
				.replace( /\[https?:\/\/[^\s\]]+\]/gi, '' )
				.replace( /''+/g, '' );
			return decodeHtmlEntities( text ).replace( /_/g, ' ' )
				.replace( /\s+/g, ' ' ).trim().toLowerCase();
		}

		function decodeHtmlEntities( text ) {
			return $( '<textarea>' ).html( text ).text();
		}

		return {
			escapeRegex: escapeRegex,
			normName: normName,
			findTemplate: findTemplate,
			splitParams: splitParams,
			matchEntryLine: matchEntryLine,
			articleLinkRegex: articleLinkRegex,
			articleLinkMatchRegex: articleLinkMatchRegex,
			findSection: findSection
		};
	}() );

	// =======================================================================
	//  Rating computation - MILHIST custom class mask
	// =======================================================================
	// MILHIST has opted OUT of project-independent quality assessment (PIQA).
	// Its B/C/Start computation lives at [[Template:WikiProject Military
	// history/class]], NOT the generic module. The rule encoded here (which a
	// coordinator should re-verify against that template via
	// Adjutant.verifyClassTemplate() ) is:
	//   - all five criteria yes                       -> B
	//   - else (B1 OR B2) AND B3 AND B4 AND B5 yes     -> C
	//   - otherwise                                    -> Start
	// We always write a class that AGREES with what the banner will render, so
	// the strike text, the talk-page note and the banner class= stay consistent.
	var Rating = ( function () {
		function compute( c ) {
			if ( c.b1 && c.b2 && c.b3 && c.b4 && c.b5 ) { return 'B'; }
			if ( ( c.b1 || c.b2 ) && c.b3 && c.b4 && c.b5 ) { return 'C'; }
			return 'Start';
		}
		// A "clean" checklist that renders the requested class, used by the
		// secondary one-click buttons (no popup).
		function checklistFor( cls ) {
			if ( cls === 'B' ) { return { b1: true, b2: true, b3: true, b4: true, b5: true }; }
			if ( cls === 'C' ) { return { b1: true, b2: false, b3: true, b4: true, b5: true }; }
			return { b1: false, b2: false, b3: false, b4: false, b5: false }; // Start
		}
		function label( cls ) { return cls + '-class'; }
		return { compute: compute, checklistFor: checklistFor, label: label };
	}() );

	// Console-only helper: fetch and log the live custom-mask template so a
	// coordinator can confirm Rating.compute matches the wiki. Read-only.
	Adjutant.verifyClassTemplate = function () {
		return Api.readPage( 'Template:WikiProject Military history/class' ).then( function ( p ) {
			mw.log( '[Adjutant] Template:WikiProject Military history/class:\n' + p.content );
			return p.content;
		} );
	};

	// =======================================================================
	//  Banner editor - {{WikiProject Military history}} / {{WPMILHIST}}
	// =======================================================================
	// Handles standalone banners and banners nested inside {{WikiProject banner
	// shell}} / {{WPBS}}. MILHIST keeps its own B-checklist on its own banner
	// even when the overall class= lives on the shell, so we set the checklist
	// (b1..b5) and class= on the MILHIST banner, and also update a shell class=
	// if one exists. Edits are minimal: we set/append only the params we change
	// and never reflow the banner. Adds the banner if absent (per config).
	var Banner = ( function () {
		var BANNER_NAMES = [ 'WikiProject Military history', 'WPMILHIST' ];
		var SHELL_NAMES = [ 'WikiProject banner shell', 'WPBS', 'WikiProjectBannerShell', 'WikiProject Banner Shell' ];
		// Canonical checklist param matcher: b1..b5 with any alias spelling,
		// e.g. b1, B1, b-1, B-1, B-Class-1, "B Class 1".
		var CHECKLIST_RE = /^\s*b[\s_-]*(?:class)?[\s_-]*([1-5])\s*$/i;

		function isChecklistParam( name ) {
			var m = CHECKLIST_RE.exec( name );
			return m ? ( 'b' + m[ 1 ] ) : null;
		}

		// Read the current checklist + class from a banner's inner text.
		function readBanner( bannerText ) {
			var inner = bannerText.replace( /^\{\{/, '' ).replace( /\}\}$/, '' );
			var params = WT.splitParams( inner );
			var out = { name: params[ 0 ].trim(), checklist: {}, classValue: null, raw: params };
			for ( var i = 1; i < params.length; i++ ) {
				var eq = params[ i ].indexOf( '=' );
				if ( eq === -1 ) { continue; }
				var key = params[ i ].slice( 0, eq ).trim();
				var val = params[ i ].slice( eq + 1 ).trim();
				var cl = isChecklistParam( key );
				if ( cl ) {
					out.checklist[ cl ] = /^yes$/i.test( val );
				} else if ( /^class$/i.test( key ) ) {
					out.classValue = val;
				}
			}
			return out;
		}

		// Set a single named param within a template's text, minimally:
		// replace the value in place if the param exists (by alias), else
		// append " |name=value" before the closing }}.
		function setParam( bannerText, displayName, matchFn, value ) {
			var open = bannerText.slice( 0, 2 ); // "{{"
			var close = bannerText.slice( -2 ); // "}}"
			var inner = bannerText.slice( 2, -2 );
			var parts = WT.splitParams( inner );
			var replaced = false;
			for ( var i = 1; i < parts.length; i++ ) {
				var eq = parts[ i ].indexOf( '=' );
				if ( eq === -1 ) { continue; }
				var key = parts[ i ].slice( 0, eq ).trim();
				if ( matchFn( key ) ) {
					// Preserve the existing key spelling and surrounding spacing.
					var prefix = parts[ i ].slice( 0, eq + 1 );
					var oldVal = parts[ i ].slice( eq + 1 );
					var lead = ( oldVal.match( /^\s*/ ) || [ '' ] )[ 0 ];
					var trail = ( oldVal.match( /\s*$/ ) || [ '' ] )[ 0 ];
					parts[ i ] = prefix + lead + value + trail;
					replaced = true;
					break;
				}
			}
			if ( !replaced ) {
				// Append a new param. Match the dominant separator style.
				var sep = /\n\s*\|/.test( inner ) ? '\n|' : '|';
				parts.push( sep + displayName + '=' + value );
			}
			return open + parts.join( '|' ) + close;
		}

		// Apply checklist + class to a banner's text.
		function applyAssessment( bannerText, checklist, cls ) {
			var text = bannerText;
			[ 'b1', 'b2', 'b3', 'b4', 'b5' ].forEach( function ( bk ) {
				var n = bk.charAt( 0 ).toUpperCase() + bk.charAt( 1 ); // "B1"
				text = setParam( text, n, function ( key ) {
					return isChecklistParam( key ) === bk;
				}, checklist[ bk ] ? 'yes' : 'no' );
			} );
			text = setParam( text, 'class', function ( key ) {
				return /^class$/i.test( key );
			}, cls );
			return text;
		}

		// Build a fresh banner when none exists.
		function buildNewBanner( checklist, cls ) {
			return '{{WikiProject Military history|class=' + cls +
				'|B1=' + ( checklist.b1 ? 'yes' : 'no' ) +
				'|B2=' + ( checklist.b2 ? 'yes' : 'no' ) +
				'|B3=' + ( checklist.b3 ? 'yes' : 'no' ) +
				'|B4=' + ( checklist.b4 ? 'yes' : 'no' ) +
				'|B5=' + ( checklist.b5 ? 'yes' : 'no' ) + '}}';
		}

		// Produce new full talk-page wikitext applying the assessment.
		// Returns the new text, or false (logging an error) on failure.
		function assess( wikitext, checklist, cls ) {
			var banner = WT.findTemplate( wikitext, BANNER_NAMES );
			if ( banner ) {
				var updated = applyAssessment( banner.text, checklist, cls );
				var result = wikitext.slice( 0, banner.start ) + updated + wikitext.slice( banner.end );
				// If the banner is wrapped in a shell that carries its own
				// class=, keep the shell class consistent too.
				result = syncShellClass( result, cls );
				return result;
			}
			// No banner present.
			if ( Config.get( 'missingBanner' ) !== 'add' ) {
				mw.log.error( '[Adjutant] MILHIST banner missing and missingBanner != add; refusing.' );
				return false;
			}
			return addBanner( wikitext, checklist, cls );
		}

		// Update a shell's class= if a shell wraps the MILHIST banner. Best
		// effort and minimal; leaves the page untouched if no shell class.
		function syncShellClass( wikitext, cls ) {
			var shell = WT.findTemplate( wikitext, SHELL_NAMES );
			if ( !shell ) { return wikitext; }
			var read = readBanner( shell.text );
			if ( read.classValue === null ) { return wikitext; } // shell sets no class
			var updated = setParam( shell.text, 'class', function ( key ) {
				return /^class$/i.test( key );
			}, cls );
			return wikitext.slice( 0, shell.start ) + updated + wikitext.slice( shell.end );
		}

		// Insert a new banner. Prefer adding inside an existing shell; else add
		// it at the very top of the talk page (above other banners is fine -
		// WikiProject banners conventionally sit at the top).
		function addBanner( wikitext, checklist, cls ) {
			var shell = WT.findTemplate( wikitext, SHELL_NAMES );
			var newBanner = buildNewBanner( checklist, cls );
			if ( shell ) {
				// Insert just before the shell's closing }}.
				var insertAt = shell.end - 2;
				var inner = wikitext.slice( shell.start, insertAt );
				var sep = /\n/.test( inner ) ? '\n' : '';
				return wikitext.slice( 0, insertAt ) + sep + newBanner + wikitext.slice( insertAt );
			}
			return newBanner + '\n' + wikitext;
		}

		// Downgrade helper for Phase 2: set class + a clean checklist for C.
		function downgradeToC( wikitext ) {
			return assess( wikitext, Rating.checklistFor( 'C' ), 'C' );
		}

		return {
			BANNER_NAMES: BANNER_NAMES,
			readBanner: readBanner,
			findBanner: function ( wt ) { return WT.findTemplate( wt, BANNER_NAMES ); },
			assess: assess,
			downgradeToC: downgradeToC,
			isChecklistParam: isChecklistParam
		};
	}() );

	// =======================================================================
	//  UI helpers - OOUI dialogs, diff preview, checklist popup, buttons
	// =======================================================================
	var UI = ( function () {
		var windowManager = null;
		function wm() {
			if ( !windowManager ) {
				windowManager = new OO.ui.WindowManager();
				$( document.body ).append( windowManager.$element );
			}
			return windowManager;
		}

		// Confirm a set of edits by previewing their diffs together. `edits`
		// is an array of { title, from, to }. Resolves true/false.
		function confirmDiffs( edits, opts ) {
			opts = opts || {};
			var $content = $( '<div>' ).addClass( 'adjutant-diff-wrap' );
			if ( Config.get( 'dryRun' ) ) {
				$content.append( $( '<p>' ).css( { fontWeight: 'bold', color: '#b32424' } )
					.text( 'DRY-RUN is ON - no edit will be saved.' ) );
			}
			var diffPromises = edits.map( function ( e ) {
				var $section = $( '<div>' ).css( { marginBottom: '1em' } );
				$section.append( $( '<p>' ).append(
					$( '<strong>' ).text( e.title )
				) );
				$content.append( $section );
				return Api.compare( e.title, e.from, e.to ).then( function ( body ) {
					var $table = $( '<table>' ).addClass( 'diff' ).attr( 'role', 'presentation' );
					$table.append( $( '<colgroup>' ).append(
						$( '<col>' ).addClass( 'diff-marker' ),
						$( '<col>' ).addClass( 'diff-content' ),
						$( '<col>' ).addClass( 'diff-marker' ),
						$( '<col>' ).addClass( 'diff-content' )
					) );
					// action=compare returns trusted wiki HTML diff rows. Insert
					// via $.parseHTML (no script execution) - never innerHTML of
					// our own constructed markup.
					$table.append( $.parseHTML( body ) );
					$section.append( $table );
				}, function () {
					$section.append( $( '<p>' ).css( 'color', '#b32424' ).text( 'Could not render diff preview.' ) );
				} );
			} );

			return $.when.apply( $, diffPromises ).then( function () {
				return OO.ui.confirm( $content, {
					title: opts.title || 'Adjutant - confirm edit',
					size: 'larger',
					actions: [
						{ action: 'reject', label: 'Cancel', flags: [ 'safe', 'close' ] },
						{ action: 'accept', label: opts.acceptLabel || 'Save', flags: [ 'primary', 'progressive' ] }
					]
				} );
			} );
		}

		// Assessment popup. Resolves { checklist, cls, explanation } or null.
		function assessmentDialog( opts ) {
			opts = opts || {};
			var initial = opts.checklist || { b1: true, b2: true, b3: true, b4: true, b5: true };
			var labels = {
				b1: 'B1. Referencing and citation',
				b2: 'B2. Coverage and accuracy',
				b3: 'B3. Structure',
				b4: 'B4. Grammar and style',
				b5: 'B5. Supporting materials'
			};
			var checks = {};
			var fields = [];
			var resultLabel = new OO.ui.LabelWidget( { label: '' } );
			function current() {
				var c = {};
				[ 'b1', 'b2', 'b3', 'b4', 'b5' ].forEach( function ( bk ) {
					c[ bk ] = checks[ bk ].isSelected();
				} );
				return c;
			}
			function updateResult() {
				var cls = Rating.compute( current() );
				resultLabel.setLabel( 'Result: ' + Rating.label( cls ) );
				resultLabel.$element.css( 'color',
					cls === 'B' ? '#14866d' : ( cls === 'C' ? '#a05a00' : '#72777d' ) );
			}
			[ 'b1', 'b2', 'b3', 'b4', 'b5' ].forEach( function ( bk ) {
				var cb = new OO.ui.CheckboxInputWidget( { selected: !!initial[ bk ] } );
				checks[ bk ] = cb;
				cb.on( 'change', updateResult );
				fields.push( new OO.ui.FieldLayout( cb, { label: labels[ bk ], align: 'inline' } ) );
			} );
			resultLabel.$element.css( { fontSize: '1.2em', fontWeight: 'bold', display: 'block', marginTop: '0.5em' } );
			var explanation = new OO.ui.MultilineTextInputWidget( {
				rows: 2,
				placeholder: 'Optional note, e.g. lead needs more battle detail'
			} );
			var fs = new OO.ui.FieldsetLayout( { label: 'B-Class checklist' } );
			fs.addItems( fields );
			var $content = $( '<div>' ).append(
				fs.$element,
				resultLabel.$element,
				new OO.ui.FieldLayout( explanation, { label: 'Explanation (optional)', align: 'top' } ).$element
			);
			updateResult();
			return OO.ui.confirm( $content, {
				title: opts.title || 'Assess article',
				size: 'medium',
				actions: [
					{ action: 'reject', label: 'Cancel', flags: [ 'safe', 'close' ] },
					{ action: 'accept', label: 'Continue', flags: [ 'primary', 'progressive' ] }
				]
			} ).then( function ( ok ) {
				if ( !ok ) { return null; }
				var checklist = current();
				return {
					checklist: checklist,
					cls: Rating.compute( checklist ),
					explanation: explanation.getValue().trim()
				};
			} );
		}

		function notify( msg, type ) {
			mw.notify( msg, { tag: 'adjutant', type: type || 'info' } );
		}

		function button( label, flags, onClick ) {
			var b = new OO.ui.ButtonWidget( {
				label: label,
				flags: flags || [],
				framed: true
			} );
			b.$element.css( { marginRight: '4px' } );
			b.on( 'click', onClick );
			return b;
		}

		return {
			confirmDiffs: confirmDiffs,
			assessmentDialog: assessmentDialog,
			notify: notify,
			button: button,
			prompt: function ( text, opts ) { return OO.ui.prompt( text, opts ); }
		};
	}() );

	// =======================================================================
	//  Portlet links: settings dialog + dry-run toggle
	// =======================================================================
	function addPortletLinks() {
		// addPortletLink abstracts the differing portlet IDs across skins
		// (Monobook vs Vector legacy vs Vector 2022), so the links render
		// identically everywhere.
		var settingsLink = mw.util.addPortletLink(
			'p-tb', '#', 'Adjutant settings', 't-adjutant-settings',
			'Configure Adjutant and view the action log'
		);
		if ( settingsLink ) {
			$( settingsLink ).on( 'click', function ( e ) { e.preventDefault(); openSettings(); } );
		}
		var dryLink = mw.util.addPortletLink(
			'p-tb', '#', dryRunLabel(), 't-adjutant-dryrun',
			'Toggle Adjutant dry-run mode'
		);
		if ( dryLink ) {
			$( dryLink ).on( 'click', function ( e ) {
				e.preventDefault();
				Config.set( 'dryRun', !Config.get( 'dryRun' ) );
				$( dryLink ).find( 'a' ).addBack( 'a' ).text( dryRunLabel() );
				$( this ).text( dryRunLabel() );
				UI.notify( 'Adjutant dry-run is now ' + ( Config.get( 'dryRun' ) ? 'ON' : 'OFF' ) );
			} );
		}
	}
	function dryRunLabel() {
		return 'Adjutant: dry-run ' + ( Config.get( 'dryRun' ) ? 'ON' : 'OFF' );
	}

	function openSettings() {
		var c = Config.all();
		var fields = {};
		fields.summarySuffix = new OO.ui.TextInputWidget( { value: c.summarySuffix } );
		fields.missingBanner = new OO.ui.DropdownInputWidget( {
			options: [
				{ data: 'add', label: 'Add the banner (default)' },
				{ data: 'skip', label: 'Refuse and warn' }
			]
		} );
		fields.missingBanner.setValue( c.missingBanner );
		fields.moveDestinations = new OO.ui.MultilineTextInputWidget( {
			value: c.moveDestinations.join( '\n' ), rows: 4
		} );
		fields.throttleDelay = new OO.ui.NumberInputWidget( { value: c.throttleDelay, min: 0, step: 500 } );
		fields.dryRun = new OO.ui.CheckboxInputWidget( { selected: c.dryRun } );
		fields.sanityChecks = new OO.ui.CheckboxInputWidget( { selected: c.sanityChecks } );

		var fs = new OO.ui.FieldsetLayout( { label: 'Adjutant settings' } );
		fs.addItems( [
			new OO.ui.FieldLayout( fields.summarySuffix, { label: 'Edit-summary suffix', align: 'top' } ),
			new OO.ui.FieldLayout( fields.missingBanner, { label: 'Missing-banner behaviour', align: 'top' } ),
			new OO.ui.FieldLayout( fields.moveDestinations, { label: 'Move-tool destinations (one per line)', align: 'top' } ),
			new OO.ui.FieldLayout( fields.throttleDelay, { label: 'Throttle delay (ms)', align: 'top' } ),
			new OO.ui.FieldLayout( fields.dryRun, { label: 'Dry-run (preview only, no save)', align: 'inline' } ),
			new OO.ui.FieldLayout( fields.sanityChecks, { label: 'Phase 5 passive sanity checks', align: 'inline' } )
		] );

		// Action log section with per-entry undo.
		var log = ActionLog.list();
		var $log = $( '<div>' ).css( { marginTop: '1em' } );
		$log.append( $( '<h3>' ).text( 'Action log (this session)' ) );
		if ( log.length === 0 ) {
			$log.append( $( '<p>' ).text( 'No actions yet.' ) );
		} else {
			var $ul = $( '<ul>' );
			log.forEach( function ( entry ) {
				var $li = $( '<li>' );
				$li.append( $( '<span>' ).text(
					( entry.dryRun ? '[dry-run] ' : '' ) + entry.page +
					( entry.revid ? ' (rev ' + entry.revid + ')' : '' ) +
					( entry.undone ? ' - undone' : '' )
				) );
				if ( !entry.dryRun && entry.revid && !entry.undone ) {
					var undoBtn = new OO.ui.ButtonWidget( { label: 'Undo', flags: [ 'destructive' ], framed: false } );
					undoBtn.on( 'click', function () {
						ActionLog.undo( entry ).then( function () {
							UI.notify( 'Undone: ' + entry.page );
							undoBtn.setDisabled( true ).setLabel( 'Undone' );
						}, function ( err ) {
							UI.notify( 'Undo failed: ' + err, 'error' );
						} );
					} );
					$li.append( ' ', undoBtn.$element );
				}
				$ul.append( $li );
			} );
			$log.append( $ul );
		}

		var $content = $( '<div>' ).append( fs.$element, $log );
		OO.ui.confirm( $content, {
			title: 'Adjutant', size: 'large',
			actions: [
				{ action: 'reject', label: 'Close', flags: [ 'safe', 'close' ] },
				{ action: 'accept', label: 'Save settings', flags: [ 'primary', 'progressive' ] }
			]
		} ).done( function ( confirmed ) {
			if ( !confirmed ) { return; }
			Config.set( 'summarySuffix', fields.summarySuffix.getValue() );
			Config.set( 'missingBanner', fields.missingBanner.getValue() );
			Config.set( 'moveDestinations', fields.moveDestinations.getValue().split( '\n' )
				.map( function ( s ) { return s.trim(); } ).filter( function ( s ) { return s; } ) );
			Config.set( 'throttleDelay', parseInt( fields.throttleDelay.getValue(), 10 ) || 0 );
			Config.set( 'dryRun', fields.dryRun.isSelected() );
			Config.set( 'sanityChecks', fields.sanityChecks.isSelected() );
			UI.notify( 'Adjutant settings saved.' );
			$( '#t-adjutant-dryrun a, #t-adjutant-dryrun' ).text( dryRunLabel() );
		} );
	}

	// =======================================================================
	//  Shared DOM-scan helpers for list-style entries (Phases 1 & 2)
	// =======================================================================
	var TS_RE = /\d{2}:\d{2}, \d{1,2} (?:January|February|March|April|May|June|July|August|September|October|November|December) \d{4} \(UTC\)/;

	function extractTimestamp( text ) {
		var m = TS_RE.exec( text );
		return m ? m[ 0 ] : null;
	}

	// First mainspace article wikilink inside an element -> page title (spaces).
	function findArticleLink( $el ) {
		var $found = $();
		$el.find( 'a' ).each( function () {
			if ( $found.length ) { return; }
			var t = $( this ).attr( 'title' );
			var href = $( this ).attr( 'href' ) || '';
			if ( !t ) { return; }
			// Mainspace only: no namespace colon, skip red-link/edit/section links.
			if ( t.indexOf( ':' ) !== -1 ) { return; }
			if ( href.indexOf( 'redlink=1' ) !== -1 ) { /* still a valid article ref */ }
			if ( $( this ).hasClass( 'mw-redirect' ) || $( this ).closest( '.mw-editsection' ).length ) { return; }
			$found = $( this );
		} );
		return $found;
	}

	function extractArticleTitle( $el ) {
		var $link = findArticleLink( $el );
		return $link.length ? $link.attr( 'title' ) : null;
	}

	// Avoid double-injecting controls on re-entry.
	function alreadyHasControls( $el ) {
		return $el.find( '.adjutant-controls' ).length > 0;
	}

	function insertEntryControls( $entry, $controls ) {
		var $article = findArticleLink( $entry );
		if ( $article.length ) {
			$controls.insertAfter( $article );
			return;
		}
		var $nested = $entry.children( 'ul, ol, dl' ).first();
		if ( $nested.length ) {
			$controls.insertBefore( $nested );
			return;
		}
		$entry.append( $controls );
	}

	// Select top-level request/report list items within a scope. Robust to
	// DiscussionTools wrapper <div>s: those defeat a direct children('ul')
	// selector (the <ul> ends up nested inside a wrapper), so we search all
	// descendant <li> and then drop anything that has an ancestor <li> -- a
	// genuine top-level bullet has none, a threaded reply does. This keeps the
	// "top-level only, skip nested replies" behaviour without assuming the list
	// sits at a particular DOM depth.
	function topLevelListItems( $scope ) {
		return $scope.find( 'li' ).addBack( 'li' ).filter( function () {
			return $( this ).parents( 'li' ).length === 0;
		} );
	}

	// =======================================================================
	//  PHASE 1 - Assessment requests
	//  Page: Wikipedia:WikiProject Military history/Assessment
	// =======================================================================
	var Phase1 = ( function () {
		var REQUESTS_PAGE = 'Wikipedia:WikiProject Military history/Assessment/Requests';

		function run() {
			var $content = $( '#mw-content-text .mw-parser-output' );
			if ( !$content.length ) { $content = $( '#mw-content-text' ); }

			// The request list is transcluded onto /Assessment but lives on
			// /Assessment/Requests. Work only on top-level open request bullets.
			var considered = 0;
			var injected = 0;
			var existing = 0;
			var noArticle = 0;
			var struck = 0;
			getRequestItems( $content ).each( function () {
				var $li = $( this );
				considered++;
				if ( alreadyHasControls( $li ) ) { existing++; return; }
				var $article = findArticleLink( $li );
				if ( !$article.length ) { noArticle++; return; }
				if ( $article.closest( 's, strike, del' ).length ) { struck++; return; }
				var ts = extractTimestamp( $li.text() );
				var title = $article.attr( 'title' );
				injectControls( $li, { title: title, timestamp: ts } );
				injected++;
			} );
			console.log( '[Adjutant] Phase 1 considered ' + considered +
				' request item(s), injected ' + injected + ' control set(s); existing=' +
				existing + ', noArticle=' + noArticle + ', struck=' + struck + '.' );
		}

		function getRequestItems( $content ) {
			var page = mw.config.get( 'wgPageName' );
			var $scope = $content;
			if ( page === 'Wikipedia:WikiProject_Military_history/Assessment' ) {
				var $heading = null;
				$content.find( 'h2, h3' ).each( function () {
					var text = headingText( $( this ) );
					if ( /^Requests for assessment$/i.test( text ) ) {
						$heading = $( this ).closest( '.mw-heading' );
						if ( !$heading.length ) {
							$heading = $( this );
						}
						return false;
					}
				} );
				if ( !$heading || !$heading.length ) {
					mw.log( '[Adjutant] no Requests for assessment section found.' );
					return $();
				}
				$scope = $heading.nextUntil( '.mw-heading, h2, h3' );
			}
			return topLevelListItems( $scope );
		}

		function headingText( $heading ) {
			return ( $heading.find( '.mw-headline' ).text() || $heading.text() )
				.replace( /\[.*?\]$/, '' ).replace( /\s+/g, ' ' ).trim();
		}

		function injectControls( $li, criteria ) {
			var $box = $( '<span>' ).addClass( 'adjutant-controls' )
				.css( { marginLeft: '8px', whiteSpace: 'nowrap', fontSize: '90%' } );
			$box.append(
				document.createTextNode( '[' ),
				inlineAction( 'Assess B', function () { doAssessB( criteria ); } ),
				document.createTextNode( ' | ' ),
				inlineAction( 'C', function () { doAssessFixed( criteria, 'C' ); } ),
				document.createTextNode( ' | ' ),
				inlineAction( 'Start', function () { doAssessFixed( criteria, 'Start' ); } ),
				document.createTextNode( ' | ' ),
				inlineAction( 'Decline', function () { doDecline( criteria ); } ),
				document.createTextNode( ']' )
			);
			insertEntryControls( $li, $box );
		}

		function inlineAction( label, onClick ) {
			return $( '<a>' ).attr( 'href', '#' ).text( label ).on( 'click', function ( e ) {
				e.preventDefault();
				onClick();
			} )[ 0 ];
		}

		// Build the strike+note transform for the request line.
		function strikeAndNote( criteria, note ) {
			return function ( wikitext ) {
				var match = WT.matchEntryLine( wikitext, criteria );
				if ( !match ) { return false; }
				var lines = match.lines;
				var line = lines[ match.lineIndex ];
				// Refuse to double-strike.
				if ( /<s>/i.test( line ) ) {
					mw.log.error( '[Adjutant] line already struck; refusing.' );
					return false;
				}
				var linkRe = WT.articleLinkMatchRegex( criteria.title );
				if ( !linkRe.test( line ) ) {
					mw.log.error( '[Adjutant] could not find article link to strike', criteria );
					return false;
				}
				var replyLead = requestReplyLead( line );
				lines[ match.lineIndex ] = line.replace( linkRe, '<s>$1</s>' );
				lines.splice( match.lineIndex + 1, 0, replyLead + note + ' ' + wikiSignature() );
				return lines.join( '\n' );
			};
		}

		function requestReplyLead( line ) {
			var lead = ( line.match( /^[#*:;]+/ ) || [ '*' ] )[ 0 ];
			if ( lead.charAt( lead.length - 1 ) === ':' ) {
				return lead + '* ';
			}
			return lead + ': ';
		}

		// Assess B-class via the same adjustable checklist popup used by
		// lower-class assessment actions.
		function doAssessB( criteria ) {
			UI.assessmentDialog( {
				title: 'Assess article',
				checklist: Rating.checklistFor( 'B' )
			} ).then( function ( res ) {
				if ( !res ) { return; }
				finishAssess( criteria, res.checklist, res.cls, res.explanation );
			} );
		}

		// Secondary one-click buttons start with a clean checklist for the class,
		// but still allow the coordinator to adjust criteria before saving.
		function doAssessFixed( criteria, cls ) {
			UI.assessmentDialog( {
				title: 'Assess article',
				checklist: Rating.checklistFor( cls )
			} ).then( function ( res ) {
				if ( !res ) { return; }
				finishAssess( criteria, res.checklist, res.cls, res.explanation );
			} );
		}

		// The combined two-page action: strike+note on the Assessment page AND
		// set the talk-page banner. Preview both diffs, then save both.
		function finishAssess( criteria, checklist, cls, explanation ) {
			var talkTitle = 'Talk:' + criteria.title;
			var note = 'Assessed ' + Rating.label( cls ) + ( explanation ? ': ' + explanation : '.' );

			var assessBuild = strikeAndNote( criteria, note );
			var bannerBuild = function ( wikitext ) {
				return Banner.assess( wikitext, checklist, cls );
			};

			$.when(
				Api.readPage( REQUESTS_PAGE ),
				Api.readPage( talkTitle )
			).then( function ( assessPage, talkPage ) {
				var newAssess = assessBuild( assessPage.content );
				var newTalk = bannerBuild( talkPage.content );
				if ( newAssess === false ) {
					UI.notify( 'Could not match the request line in wikitext - no edit made. See console.', 'error' );
					return;
				}
				if ( newTalk === false ) {
					UI.notify( 'Could not edit the MILHIST banner - no edit made. See console.', 'error' );
					return;
				}
				UI.confirmDiffs( [
					{ title: REQUESTS_PAGE, from: assessPage.content, to: newAssess },
					{ title: talkTitle, from: talkPage.content, to: newTalk }
				], { title: 'Assess ' + Rating.label( cls ), acceptLabel: 'Save both' } ).done( function ( ok ) {
					if ( !ok ) { return; }
					Api.save( { title: REQUESTS_PAGE, summary: note + ' request', build: assessBuild } )
						.then( function () {
							return Api.save( {
								title: talkTitle,
								summary: note,
								build: bannerBuild
							} );
						} )
						.then( function () { UI.notify( 'Assessed ' + Rating.label( cls ) + ': ' + criteria.title ); },
							function ( err ) { UI.notify( 'Save failed: ' + err, 'error' ); } );
				} );
			}, function ( err ) {
				UI.notify( 'Could not read pages: ' + err, 'error' );
			} );
		}

		function doDecline( criteria ) {
			UI.prompt( 'Decline reason (one line):', { textInput: { placeholder: 'e.g. needs inline citations first' } } )
				.done( function ( reason ) {
					if ( reason === null ) { return; }
					reason = ( reason || '' ).trim();
					var note = 'Declined' + ( reason ? ': ' + reason : '.' );
					var build = strikeAndNote( criteria, note );
					Api.readPage( REQUESTS_PAGE ).then( function ( page ) {
						var newText = build( page.content );
						if ( newText === false ) {
							UI.notify( 'Could not match the request line - no edit made.', 'error' );
							return;
						}
						UI.confirmDiffs( [ { title: REQUESTS_PAGE, from: page.content, to: newText } ],
							{ title: 'Decline request', acceptLabel: 'Save' } ).done( function ( ok ) {
							if ( !ok ) { return; }
							Api.save( { title: REQUESTS_PAGE, summary: 'Declined assessment request', build: build } )
								.then( function () { UI.notify( 'Declined: ' + criteria.title ); },
									function ( err ) { UI.notify( 'Save failed: ' + err, 'error' ); } );
						} );
					} );
				} );
		}

		return { run: run };
	}() );

	// =======================================================================
	//  PHASE 2 - AutoCheck report
	//  Page: Wikipedia talk:WikiProject Military history/Coordinators
	//  Section: "AutoCheck report for [Month]"
	// =======================================================================
	var Phase2 = ( function () {
		var COORD_PAGE = 'Wikipedia talk:WikiProject Military history/Coordinators';

		function run() {
			var $content = $( '#mw-content-text .mw-parser-output' );
			if ( !$content.length ) { $content = $( '#mw-content-text' ); }

			// Find all AutoCheck section headings, then operate on each list.
			var headings = [];
			$content.find( 'h2, h3' ).each( function () {
				var t = $( this ).find( '.mw-headline' ).text() || $( this ).text();
				if ( /AutoCheck report/i.test( t ) ) {
					var $heading = $( this ).closest( '.mw-heading' );
					if ( !$heading.length ) {
						$heading = $( this );
					}
					headings.push( $heading );
				}
			} );
			if ( !headings.length ) {
				mw.log( '[Adjutant] no AutoCheck report section found.' );
				return;
			}
			headings.forEach( processAutoCheckSection );
		}

		function processAutoCheckSection( $heading ) {
			var headingLabel = sectionHeadingText( $heading );
			var considered = 0;
			var injected = 0;
			var existing = 0;
			var noArticle = 0;
			var struck = 0;
			getAutoCheckItems( $heading ).each( function () {
				var $li = $( this );
				considered++;
				if ( alreadyHasControls( $li ) ) { existing++; return; }
				var $article = findArticleLink( $li );
				if ( !$article.length ) { noArticle++; return; }
				if ( $article.closest( 's, strike, del' ).length ) { struck++; return; }
				var title = $article.attr( 'title' );
				injectControls( $li, { title: title, timestamp: extractTimestamp( $li.text() ) } );
				injected++;
			} );
			console.log( '[Adjutant] Phase 2 section "' + headingLabel + '" considered ' + considered +
				' AutoCheck item(s), injected ' + injected + ' control set(s); existing=' +
				existing + ', noArticle=' + noArticle + ', struck=' + struck + '.' );
		}

		function getAutoCheckItems( $heading ) {
			var $scope = $heading.nextUntil( '.mw-heading, h2, h3' );
			return topLevelListItems( $scope );
		}

		function sectionHeadingText( $heading ) {
			return ( $heading.find( '.mw-headline' ).text() || $heading.text() )
				.replace( /\[.*?\]$/, '' ).replace( /\s+/g, ' ' ).trim();
		}

		function injectControls( $li, criteria ) {
			var $box = $( '<span>' ).addClass( 'adjutant-controls' )
				.css( { marginLeft: '8px', whiteSpace: 'nowrap', fontSize: '90%' } );
			$box.append(
				document.createTextNode( '[' ),
				inlineAction( 'Confirm', function () { doConfirm( criteria ); } ),
				document.createTextNode( ' | ' ),
				inlineAction( 'Downgrade', function () { doDowngrade( criteria ); } ),
				document.createTextNode( ']' )
			);
			insertEntryControls( $li, $box );
		}

		function inlineAction( label, onClick ) {
			return $( '<a>' ).attr( 'href', '#' ).text( label ).on( 'click', function ( e ) {
				e.preventDefault();
				onClick();
			} )[ 0 ];
		}

		// Both outcomes STRIKE the line (current practice - never delete).
		function strikeWithNote( criteria, note ) {
			return function ( wikitext ) {
				var match = WT.matchEntryLine( wikitext, criteria );
				if ( !match ) { return false; }
				var lines = match.lines;
				var line = lines[ match.lineIndex ];
				if ( /<s>/i.test( line ) ) {
					mw.log.error( '[Adjutant] line already struck; refusing.' );
					return false;
				}
				var linkRe = WT.articleLinkMatchRegex( criteria.title );
				if ( !linkRe.test( line ) ) {
					mw.log.error( '[Adjutant] could not find AutoCheck article link to strike', criteria );
					return false;
				}
				var replyLead = autoCheckReplyLead( line );
				lines[ match.lineIndex ] = line.replace( linkRe, '<s>$1</s>' );
				lines.splice( match.lineIndex + 1, 0, replyLead + note + ' ' + wikiSignature() );
				return lines.join( '\n' );
			};
		}

		function autoCheckReplyLead( line ) {
			var lead = ( line.match( /^[#*:;]+/ ) || [ '*' ] )[ 0 ];
			if ( lead.charAt( lead.length - 1 ) === ':' ) {
				return lead + '* ';
			}
			return lead + ': ';
		}

		function doConfirm( criteria ) {
			var build = strikeWithNote( criteria, 'confirmed.' );
			Api.readPage( COORD_PAGE ).then( function ( page ) {
				var newText = build( page.content );
				if ( newText === false ) {
					UI.notify( 'Could not match the AutoCheck line - no edit made.', 'error' );
					return;
				}
				UI.confirmDiffs( [ { title: COORD_PAGE, from: page.content, to: newText } ],
					{ title: 'Confirm AutoCheck', acceptLabel: 'Save' } ).done( function ( ok ) {
					if ( !ok ) { return; }
					Api.save( { title: COORD_PAGE, summary: 'AutoCheck confirmed', build: build } )
						.then( function () { UI.notify( 'Confirmed: ' + criteria.title ); },
							function ( err ) { UI.notify( 'Save failed: ' + err, 'error' ); } );
				} );
			} );
		}

		// Downgrade/reassess: strike + note on the report AND update the
		// banner to whatever B/C/Start class the checklist computes.
		function doDowngrade( criteria ) {
			UI.assessmentDialog( {
				title: 'Assess article',
				checklist: Rating.checklistFor( 'C' )
			} ).then( function ( res ) {
				if ( !res ) { return; }
				var note = 'Assessed ' + Rating.label( res.cls ) + ( res.explanation ? ': ' + res.explanation : '.' );
				var talkTitle = 'Talk:' + criteria.title;
				var reportBuild = strikeWithNote( criteria, note );
				var bannerBuild = function ( wikitext ) { return Banner.assess( wikitext, res.checklist, res.cls ); };

				$.when(
					Api.readPage( COORD_PAGE ),
					Api.readPage( talkTitle )
				).then( function ( report, talk ) {
					var newReport = reportBuild( report.content );
					var newTalk = bannerBuild( talk.content );
					if ( newReport === false ) {
						UI.notify( 'Could not match the AutoCheck line - no edit made.', 'error' );
						return;
					}
					if ( newTalk === false ) {
						UI.notify( 'Could not edit the MILHIST banner - no edit made.', 'error' );
						return;
					}
					UI.confirmDiffs( [
						{ title: COORD_PAGE, from: report.content, to: newReport },
						{ title: talkTitle, from: talk.content, to: newTalk }
					], { title: 'Assess ' + Rating.label( res.cls ), acceptLabel: 'Save both' } ).done( function ( ok ) {
						if ( !ok ) { return; }
						Api.save( { title: COORD_PAGE, summary: note + ' AutoCheck report', build: reportBuild } )
							.then( function () {
								return Api.save( { title: talkTitle, summary: note, build: bannerBuild } );
							} )
							.then( function () { UI.notify( 'Assessed ' + Rating.label( res.cls ) + ': ' + criteria.title ); },
								function ( err ) { UI.notify( 'Save failed: ' + err, 'error' ); } );
					} );
				} );
			} );
		}

		return { run: run };
	}() );

	// =======================================================================
	//  PHASE 3 - Move / refile tool
	//  Page: Wikipedia talk:WikiProject Military history
	// =======================================================================
	var Phase3 = ( function () {
		var SOURCE_PAGE = 'Wikipedia talk:WikiProject Military history';
		var RFI_PAGE = 'Wikipedia talk:WikiProject Military history/Requests for project input';

		function run() {
			var $content = $( '#mw-content-text .mw-parser-output' );
			if ( !$content.length ) { $content = $( '#mw-content-text' ); }
			$content.find( 'h2' ).each( function () {
				var $h = $( this );
				var $headline = $h.find( '.mw-headline' );
				var heading = ( $headline.text() || $h.clone().children( '.mw-editsection' ).remove().end().text() ).trim();
				if ( !heading ) { return; }
				if ( $h.find( '.adjutant-controls' ).length ) { return; }
				injectControl( $h, heading );
			} );
		}

		function injectControl( $h, heading ) {
			var $box = $( '<span>' ).addClass( 'adjutant-controls' )
				.css( { marginLeft: '8px', whiteSpace: 'nowrap', fontSize: '90%', fontWeight: 'normal' } );
			$box.append(
				document.createTextNode( '[' ),
				$( '<a>' ).attr( 'href', '#' ).text( 'Move' ).on( 'click', function ( e ) {
					e.preventDefault();
					openMoveDialog( heading );
				} )[ 0 ],
				document.createTextNode( ']' )
			);
			$h.append( $box );
		}

		function openMoveDialog( heading ) {
			var dests = Config.get( 'moveDestinations' );
			var dropdown = new OO.ui.DropdownInputWidget( {
				options: dests.map( function ( d ) { return { data: d, label: d }; } )
			} );
			dropdown.setValue( dests[ 0 ] );
			var fs = new OO.ui.FieldsetLayout();
			fs.addItems( [
				new OO.ui.FieldLayout( new OO.ui.LabelWidget( { label: 'Thread: ' + heading } ), { align: 'top' } ),
				new OO.ui.FieldLayout( dropdown, { label: 'Move to:', align: 'top' } )
			] );
			OO.ui.confirm( $( '<div>' ).append( fs.$element ), {
				title: 'Move thread', size: 'medium',
				actions: [
					{ action: 'reject', label: 'Cancel', flags: [ 'safe', 'close' ] },
					{ action: 'accept', label: 'Continue', flags: [ 'primary', 'progressive' ] }
				]
			} ).done( function ( ok ) {
				if ( !ok ) { return; }
				performMove( heading, dropdown.getValue() );
			} );
		}

		// Cross-page cut-and-paste, previewed as two diffs, saved as a paired
		// action with cross-referencing summaries. No {{moved discussion}}.
		function performMove( heading, destTitle ) {
			$.when(
				Api.readPage( SOURCE_PAGE ),
				Api.readPage( destTitle )
			).then( function ( src, dest ) {
				var section = WT.findSection( src.content, heading );
				if ( !section ) {
					UI.notify( 'Could not locate the thread in source wikitext - no edit made.', 'error' );
					return;
				}
				var threadText = src.content.slice( section.start, section.end ).replace( /\s+$/, '' );
				var destThreadText = shouldDemoteMovedThread( destTitle ) ?
					demoteHeadingLevels( threadText ) : threadText;

				var srcBuild = function ( wikitext ) {
					var s = WT.findSection( wikitext, heading );
					if ( !s ) { return false; }
					// Remove the thread (collapse the surrounding blank lines).
					return ( wikitext.slice( 0, s.start ) + wikitext.slice( s.end ) ).replace( /\n{3,}/g, '\n\n' );
				};
				var destBuild = function ( wikitext ) {
					var base = wikitext.replace( /\s+$/, '' );
					return base + '\n\n' + destThreadText + '\n';
				};

				var newSrc = srcBuild( src.content );
				var newDest = destBuild( dest.content );
				if ( newSrc === false ) {
					UI.notify( 'Could not remove the thread - no edit made.', 'error' );
					return;
				}
				UI.confirmDiffs( [
					{ title: SOURCE_PAGE, from: src.content, to: newSrc },
					{ title: destTitle, from: dest.content, to: newDest }
				], { title: 'Move thread', acceptLabel: 'Save both' } ).done( function ( ok ) {
					if ( !ok ) { return; }
					Api.save( {
						title: SOURCE_PAGE,
						summary: 'Moved thread "' + heading + '" to [[' + destTitle + ']]',
						build: srcBuild
					} ).then( function () {
						return Api.save( {
							title: destTitle,
							summary: 'Moved thread "' + heading + '" from [[' + SOURCE_PAGE + ']]',
							build: destBuild
						} );
					} ).then( function () { UI.notify( 'Moved: ' + heading ); },
						function ( err ) { UI.notify( 'Save failed: ' + err, 'error' ); } );
				} );
			} );
		}

		function shouldDemoteMovedThread( destTitle ) {
			return WT.normName( destTitle ) === WT.normName( RFI_PAGE );
		}

		function demoteHeadingLevels( wikitext ) {
			return wikitext.replace( /^(={2,5})([^=\n].*?[^=\n])\1\s*$/gm, function ( full, marks, title ) {
				var newMarks = marks + '=';
				return newMarks + title + newMarks;
			} );
		}

		return { run: run };
	}() );

	// =======================================================================
	//  PHASE 4 - A-Class review reminder
	//  Activates on A-Class review subpages and the project talk page.
	//  Posts to the project talk page (the reminder target).
	// =======================================================================
	var Phase4 = ( function () {
		var POST_TARGET = 'Wikipedia talk:WikiProject Military history';

		function appliesTo( page ) {
			return page === 'Wikipedia_talk:WikiProject_Military_history' ||
				/A-Class_review/i.test( page );
		}

		function run() {
			var page = mw.config.get( 'wgPageName' );
			var isProjectTalk = page === 'Wikipedia_talk:WikiProject_Military_history';
			var isAClassReviewArea = /A-Class_review/i.test( page ) || hasAClassReviewReturnLink();
			if ( !isProjectTalk && !isAClassReviewArea ) {
				console.log( '[Adjutant] Phase 4 skipped on ' + page + '.' );
				return;
			}
			var link = mw.util.addPortletLink(
				'p-cactions', '#', 'A-Class reminder', 'ca-adjutant-acr',
				'Post the stale A-Class review reminder'
			);
			var portlet = 'p-cactions';
			if ( !link ) {
				link = mw.util.addPortletLink(
					'p-tb', '#', 'A-Class reminder', 't-adjutant-acr',
					'Post the stale A-Class review reminder'
				);
				portlet = 'p-tb';
			}
			if ( link ) {
				$( link ).on( 'click', function ( e ) { e.preventDefault(); openReminder(); } );
			}
			console.log( '[Adjutant] Phase 4 ' + ( link ? 'added A-Class reminder in ' + portlet :
				'could not add A-Class reminder link' ) + '; inline review controls are not part of Phase 4.' );
		}

		function hasAClassReviewReturnLink() {
			var found = false;
			$( '#mw-content-text a' ).each( function () {
				var $a = $( this );
				var href = $a.attr( 'href' ) || '';
				if ( /\/wiki\/Wikipedia:WikiProject_Military_history\/Assessment\/A-Class_review(?:$|[?#])/.test( href ) &&
					/Return to A-Class review list/i.test( $a.text() ) ) {
					found = true;
					return false;
				}
			} );
			return found;
		}

		function openReminder() {
			// Prefill the article name from the current title where sensible.
			var guess = '';
			var page = mw.config.get( 'wgPageName' );
			var m = /A-Class_review\/(.+?)(?:\/archive\d+)?$/i.exec( page ) ||
				/\/Assessment\/(.+?)$/.exec( page );
			if ( m ) { guess = m[ 1 ].replace( /_/g, ' ' ); }

			UI.prompt( 'Article name for the A-Class review reminder:', {
				textInput: { value: guess }
			} ).done( function ( name ) {
				if ( name === null ) { return; }
				name = ( name || '' ).trim();
				if ( !name ) { UI.notify( 'No article name given.', 'error' ); return; }
				// The opening braces and the "subst:" keyword are split for the
				// same save-time PST reason as wikiSignature() above: writing
				// the brace-subst form literally in this source would be
				// expanded when the .js page is saved. At runtime this
				// reassembles the real boilerplate, which the server
				// substitutes on the script's own edit. Do not rejoin into a
				// literal.
				var boilerplate = '{{' + 'subst:Wikipedia:WikiProject Military history/Coordinators/Toolbox/A-Class review alert|' +
					name + '}} ' + wikiSignature();
				var build = function ( wikitext ) {
					return wikitext.replace( /\s+$/, '' ) + '\n\n' + boilerplate + '\n';
				};
				Api.readPage( POST_TARGET ).then( function ( page2 ) {
					var newText = build( page2.content );
					UI.confirmDiffs( [ { title: POST_TARGET, from: page2.content, to: newText } ],
						{ title: 'Post A-Class reminder', acceptLabel: 'Post' } ).done( function ( ok ) {
						if ( !ok ) { return; }
						Api.save( { title: POST_TARGET, summary: 'A-Class review reminder for [[' + name + ']]', build: build } )
							.then( function () { UI.notify( 'Reminder posted for ' + name ); },
								function ( err ) { UI.notify( 'Save failed: ' + err, 'error' ); } );
					} );
				} );
			} );
		}

		return { appliesTo: appliesTo, run: run };
	}() );

	// =======================================================================
	//  PHASE 5 - Passive sanity checks & backlog links (read-only, no edits)
	// =======================================================================
	var Phase5 = ( function () {
		function appliesTo() {
			// Talk pages that may carry a MILHIST banner.
			return mw.config.get( 'wgNamespaceNumber' ) % 2 === 1 ||
				mw.config.get( 'wgPageName' ).indexOf( 'WikiProject_Military_history' ) !== -1;
		}

		function run() {
			// Read the talk page banner, no edits, and surface inline flags.
			var talkTitle = mw.config.get( 'wgPageName' ).replace( /_/g, ' ' );
			Api.readPage( talkTitle ).then( function ( page ) {
				if ( page.missing ) { return; }
				var banner = Banner.findBanner( page.content );
				if ( !banner ) { return; }
				var read = Banner.readBanner( banner.text );
				var flags = [];

				// 1. class=B with an incomplete/failing checklist (BCAD).
				if ( read.classValue && /^b$/i.test( read.classValue ) ) {
					var allYes = [ 'b1', 'b2', 'b3', 'b4', 'b5' ].every( function ( k ) { return read.checklist[ k ] === true; } );
					if ( !allYes ) {
						flags.push( 'class=B but the B-checklist is incomplete or failing (BCAD).' );
					}
				}
				// 2. A rating that outranks listed status (heuristic).
				if ( read.classValue && /^(A|GA|FA)$/i.test( read.classValue ) ) {
					var hasListing = /\{\{\s*(?:Good article|featured article|ArticleHistory|Article history)/i.test( page.content );
					if ( !hasListing ) {
						flags.push( 'Banner shows ' + read.classValue + '-class but no corresponding GA/FA/ACR listing was found.' );
					}
				}
				if ( flags.length ) { showPanel( flags ); }
			} );
		}

		function showPanel( flags ) {
			var $panel = $( '<div>' ).addClass( 'adjutant-sanity' ).css( {
				border: '1px solid #c8ccd1', background: '#f8f9fa', padding: '8px',
				margin: '8px 0', fontSize: '0.9em'
			} );
			$panel.append( $( '<strong>' ).text( 'Adjutant sanity checks' ) );
			var $ul = $( '<ul>' ).css( 'margin', '4px 0 4px 1.5em' );
			flags.forEach( function ( f ) { $ul.append( $( '<li>' ).text( f ) ); } );
			$panel.append( $ul );
			// Backlog quick links.
			$panel.append( $( '<div>' )
				.append( $( '<a>' ).attr( 'href', mw.util.getUrl( 'Category:Military history articles with incomplete B-Class checklists' ) ).text( 'Incomplete-checklist backlog' ) )
				.append( document.createTextNode( ' | ' ) )
				.append( $( '<a>' ).attr( 'href', mw.util.getUrl( 'Category:Unassessed military history articles' ) ).text( 'Unassessed backlog' ) )
			);
			var $content = $( '#mw-content-text .mw-parser-output' );
			if ( !$content.length ) { $content = $( '#mw-content-text' ); }
			$content.prepend( $panel );
		}

		return { appliesTo: appliesTo, run: run };
	}() );

	// Expose modules for console debugging / re-verification.
	Adjutant.Config = Config;
	Adjutant.Rating = Rating;
	Adjutant.Banner = Banner;
	Adjutant.WT = WT;

	// Diagnostic helper. Run Adjutant.debug() from the browser console on a
	// target page to see exactly what the entry detector finds and how the
	// rendered DOM / source wikitext are structured. Uses console.* directly
	// because mw.log is a no-op outside ResourceLoader debug mode.
	Adjutant.debug = function () {
		var page = mw.config.get( 'wgPageName' );
		var $c = $( '#mw-content-text .mw-parser-output' );
		if ( !$c.length ) { $c = $( '#mw-content-text' ); }
		console.log( '[Adjutant debug] page =', page, '| content roots =', $c.length );

		var sel = 'li, dd, dt, p';
		var $els = $c.find( sel );
		var hits = [];
		$els.each( function () {
			var ts = extractTimestamp( $( this ).text() );
			if ( ts ) {
				hits.push( {
					tag: this.tagName,
					ts: ts,
					title: extractArticleTitle( $( this ) ),
					html: ( this.outerHTML || '' ).replace( /\s+/g, ' ' ).slice( 0, 320 )
				} );
			}
		} );
		console.log( '[Adjutant debug]', sel, 'scanned =', $els.length,
			'| containing a UTC signature =', hits.length );
		hits.slice( 0, 6 ).forEach( function ( h, i ) {
			console.log( '  [' + i + '] <' + h.tag + '> title=' + JSON.stringify( h.title ) +
				' ts=' + JSON.stringify( h.ts ) );
			console.log( '       html: ' + h.html );
		} );

		// Pull the source wikitext around the first detected signature so we
		// can see how a request line is actually formatted.
		if ( hits.length ) {
			Api.readPage( page.replace( /_/g, ' ' ) ).then( function ( p ) {
				var idx = p.content.indexOf( hits[ 0 ].ts );
				if ( idx === -1 ) {
					console.log( '[Adjutant debug] first rendered signature not found verbatim in source.' );
					return;
				}
				console.log( '[Adjutant debug] source wikitext around first signature:\n' +
					p.content.slice( Math.max( 0, idx - 400 ), idx + 120 ) );
			} );
		}
		return hits.length;
	};

}() );