User:Swatjester/Adjutant.js
Appearance
Code that you insert on this page could contain malicious content capable of compromising your account. If you import a script from another page with "importScript", "mw.loader.load", "iusc", or "lusc", take note that this causes you to dynamically load a remote script, which could be changed by others. Editors are responsible for all edits and actions they perform, including by scripts. User scripts are not centrally supported and may malfunction or become inoperable due to software changes. A guide to help you find broken scripts is available. If you are unsure whether code you are adding to this page is safe, you can ask at the appropriate village pump.
This code will be executed when previewing this page.
This code will be executed when previewing this page.
This user script seems to have a documentation page at User:Swatjester/Adjutant.
/**
* 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;
};
}() );