Soft launch: Soldier biographies and research notes vary in completeness. Prefer class-roster, NARA, newspapers, and other cited sources for contested facts — treat Research notes as leads, not settled history. Anyone can correct a page; contact us with questions.

MediaWiki:Common.js: Difference between revisions

From Ritchie Boys Wiki
Jump to navigation Jump to search
Empty search help; Biography wording
Add Internet Archive click-to-play embeds for interview watchlist
 
(21 intermediate revisions by the same user not shown)
Line 9: Line 9:
  * Hide retired “(merged duplicate)” roster pages from find/search suggestions and results.
  * Hide retired “(merged duplicate)” roster pages from find/search suggestions and results.
  * Fill soldier Teammates rails from live Category:Team … membership (no roster-module rebuild).
  * Fill soldier Teammates rails from live Category:Team … membership (no roster-module rebuild).
* Inject site hub links into the Minerva hamburger (Sidebar is not used by Minerva 1.43).
  */
  */
function rbIsMergedDuplicateTitle(title) {
function rbIsMergedDuplicateTitle(title) {
Line 15: Line 16:


$(function () {
$(function () {
(function injectMinervaSiteMenu() {
if (mw.config.get('skin') !== 'minerva') {
return;
}
var $drawer = $('#mw-mf-page-left');
var $after = $('#p-navigation');
if (!$drawer.length || !$after.length || $drawer.data('rbSiteMenu')) {
return;
}
$drawer.data('rbSiteMenu', 1);
var links = [
{ title: 'Contents', label: 'Contents', icon: 'listBullet' },
{ title: 'Start here', label: 'Start here', icon: 'lightbulb' },
{ title: 'Introduction', label: 'Introduction', icon: 'book' },
{ title: 'The Stories', label: 'Stories', icon: 'newspaper' },
{ title: 'Category:Classes', label: 'Classes', icon: 'userGroup' },
{ title: 'Photos', label: 'Photos', icon: 'imageGallery' },
{ title: 'Training materials', label: 'Training materials', icon: 'articles' },
{ title: 'Contribute', label: 'Contribute', icon: 'edit' },
{ title: 'Contact Us', label: 'Contact Us', icon: 'speechBubbles' },
{ title: 'About', label: 'About', icon: 'infoFilled' }
];
var $ul = $('<ul id="p-ritchie" class="toggle-list__list"></ul>');
links.forEach(function (item) {
var name = 'rb-' + String(item.title).toLowerCase().replace(/[^a-z0-9]+/g, '-');
var $a = $('<a class="toggle-list-item__anchor"></a>')
.addClass('menu__item--' + name)
.attr('href', mw.util.getUrl(item.title));
$a.append($('<span class="minerva-icon"></span>').addClass('minerva-icon--' + item.icon));
$a.append($('<span class="toggle-list-item__label"></span>').text(item.label));
$ul.append($('<li class="toggle-list-item"></li>').append($a));
});
$after.after($ul);
}());
(function buildHomeFind() {
(function buildHomeFind() {
var $slot = $('.mw-parser-output .home-find-slot').first();
var $slot = $('.mw-parser-output .home-find-slot').first();
Line 510: Line 546:
});
});


var $cards = $('.mw-parser-output .yt-interview[data-youtube-id]');
function interviewCardRef($el) {
var yt = String($el.attr('data-youtube-id') || '');
if (yt) {
return { key: 'yt:' + yt, href: '#yt-' + yt };
}
var sc = String($el.attr('data-soundcloud-track') || '');
if (sc) {
return { key: 'sc:' + sc, href: '#sc-' + sc };
}
return null;
}
 
var $cards = $('.mw-parser-output .yt-interview').filter(function () {
return interviewCardRef($(this)) !== null;
});
$cards.each(function (idx) {
$cards.each(function (idx) {
var $card = $(this);
var $card = $(this);
Line 522: Line 572:
}
}
var mine = splitSoldiers($card.attr('data-soldiers'));
var mine = splitSoldiers($card.attr('data-soldiers'));
var selfRef = interviewCardRef($card);
var picks = [];
var picks = [];
var seen = {};
var seen = {};


function addCard($other, why) {
function addCard($other, why) {
var oid = String($other.attr('data-youtube-id') || '');
var ref = interviewCardRef($other);
if (!oid || seen[oid] || oid === $card.attr('data-youtube-id')) {
if (!ref || !selfRef || seen[ref.key] || ref.key === selfRef.key) {
return;
return;
}
}
seen[oid] = 1;
seen[ref.key] = 1;
picks.push({
picks.push({
id: oid,
href: ref.href,
heading: String($other.attr('data-heading') || $other.find('h3, h2').first().text() || 'Interview'),
heading: String($other.attr('data-heading') || $other.find('h3, h2').first().text() || 'Interview'),
short: String($other.attr('data-short') || ''),
short: String($other.attr('data-short') || ''),
Line 557: Line 608:
picks.slice(0, 3).forEach(function (item) {
picks.slice(0, 3).forEach(function (item) {
var label = item.short ? (item.heading + ' — ' + item.short) : item.heading;
var label = item.short ? (item.heading + ' — ' + item.short) : item.heading;
var $a = $('<a></a>').attr('href', '#yt-' + item.id).text(label);
var $a = $('<a></a>').attr('href', item.href).text(label);
var $li = $('<li></li>').append($a);
var $li = $('<li></li>').append($a);
if (item.why === 'same soldier') {
if (item.why === 'same soldier') {
Line 570: Line 621:
}());
}());


(function placeSoldierAwardsSection() {
(function buildSoundCloudEmbeds() {
// Ribbon racks belong in the main column after Biography (like Wikipedia's
var SC_TRACK = /^[0-9]{6,12}$/;
// Awards section). Do not use a wiki == heading here — that stole the TOC.
 
var $out = $('.mw-parser-output').first();
function playSoundCloud($slot, trackId, title) {
var $awards = $out.find('.soldier-awards-section').first();
var src = 'https://w.soundcloud.com/player/?url=' +
if (!$out.length) {
encodeURIComponent('https://api.soundcloud.com/tracks/' + trackId) +
return;
'&color=%23ff5500&auto_play=true&hide_related=true&show_comments=false' +
'&show_user=true&show_reposts=false&show_teaser=false&visual=true';
var $iframe = $('<iframe class="sc-embed-frame" scrolling="no" frameborder="no" allow="autoplay"></iframe>')
.attr({
src: src,
title: title || 'SoundCloud audio',
loading: 'lazy'
});
$slot.empty().append($iframe).addClass('sc-embed-playing');
}
}


function sectionAfter($h) {
$('.mw-parser-output .sc-embed[data-soundcloud-track]').each(function () {
var $nodes = $h.nextUntil('h2');
var $slot = $(this);
if ($nodes.length) {
if ($slot.data('rbScEmbed')) {
return $nodes.last();
return;
}
var trackId = String($slot.attr('data-soundcloud-track') || '');
if (!SC_TRACK.test(trackId)) {
return;
}
}
return $h;
$slot.data('rbScEmbed', 1);
}
var title = String($slot.attr('data-title') || 'SoundCloud audio');
 
var $fallback = $slot.find('.sc-embed-fallback').first();
if ($awards.length && !$awards.data('rbAwardsPlaced')) {
var $btn = $('<button type="button" class="sc-embed-play"></button>')
$awards.data('rbAwardsPlaced', 1);
.attr('aria-label', 'Play ' + title)
var $bio = $out.find('h2').filter(function () {
.append($('<span class="sc-embed-play-icon" aria-hidden="true"></span>'))
var t = $(this).find('.mw-headline').text() || $(this).text();
.append($('<span class="sc-embed-play-text"></span>').text('Play audio'));
return /^(Biography|Life)\b/i.test($.trim(t));
var $poster = $('<div class="sc-embed-poster"></div>').append($btn);
}).first();
if ($fallback.length) {
if ($bio.length) {
$fallback.before($poster);
sectionAfter($bio).after($awards);
$fallback.hide();
} else {
} else {
var $before = $out.find('h2').filter(function () {
$slot.prepend($poster);
var t = $(this).find('.mw-headline').text() || $(this).text();
return /^(Gallery|Research notes|Sources|Technical record)\b/i.test($.trim(t));
}).first();
if ($before.length) {
$before.before($awards);
} else {
$out.append($awards);
}
}
}
$poster.on('click', function (ev) {
ev.preventDefault();
playSoundCloud($slot, trackId, title);
});
});
}());
(function buildAviaryEmbeds() {
var AV_MEDIA = /^[0-9]{4,12}$/;
function playAviary($slot, mediaId, title) {
var src = 'https://milken.aviaryplatform.com/embed/media/' +
encodeURIComponent(mediaId) +
'?embed=true&media_player=true';
var $iframe = $('<iframe class="aviary-embed-frame" allowfullscreen allow="autoplay; encrypted-media; fullscreen; picture-in-picture"></iframe>')
.attr({
src: src,
title: title || 'Aviary media',
loading: 'lazy'
});
$slot.empty().append($iframe).addClass('aviary-embed-playing');
}
}


// Class/team rail rides with awards (after Biography).
$('.mw-parser-output .aviary-embed[data-aviary-media]').each(function () {
var $related = $out.find('.soldier-related').first();
var $slot = $(this);
if ($related.length && !$related.data('rbRelatedPlaced')) {
if ($slot.data('rbAviaryEmbed')) {
$related.data('rbRelatedPlaced', 1);
return;
var $awardsNow = $out.find('.soldier-awards-section').first();
}
if ($awardsNow.length) {
var mediaId = String($slot.attr('data-aviary-media') || '');
$awardsNow.after($related);
if (!AV_MEDIA.test(mediaId)) {
return;
}
$slot.data('rbAviaryEmbed', 1);
var title = String($slot.attr('data-title') || 'Aviary media');
var playLabel = String($slot.attr('data-play-label') || 'Play video');
var $fallback = $slot.find('.aviary-embed-fallback').first();
var $btn = $('<button type="button" class="aviary-embed-play"></button>')
.attr('aria-label', playLabel + ' — ' + title)
.append($('<span class="aviary-embed-play-icon" aria-hidden="true"></span>'))
.append($('<span class="aviary-embed-play-text"></span>').text(playLabel));
var $poster = $('<div class="aviary-embed-poster"></div>').append($btn);
if ($fallback.length) {
$fallback.before($poster);
$fallback.hide();
} else {
} else {
var $bio2 = $out.find('h2').filter(function () {
$slot.prepend($poster);
var t = $(this).find('.mw-headline').text() || $(this).text();
return /^(Biography|Life)\b/i.test($.trim(t));
}).first();
if ($bio2.length) {
sectionAfter($bio2).after($related);
}
}
}
$poster.on('click', function (ev) {
ev.preventDefault();
playAviary($slot, mediaId, title);
});
});
}());
(function buildInternetArchiveEmbeds() {
// archive.org item identifiers (e.g. RobertA.Potash13january2012YiddishBookCenter)
var IA_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{5,120}$/;
function playInternetArchive($slot, itemId, title) {
var src = 'https://archive.org/embed/' + encodeURIComponent(itemId);
var $iframe = $('<iframe class="ia-embed-frame" allowfullscreen allow="autoplay; encrypted-media; fullscreen; picture-in-picture"></iframe>')
.attr({
src: src,
title: title || 'Internet Archive media',
loading: 'lazy'
});
$slot.empty().append($iframe).addClass('ia-embed-playing');
}
}


// Story cue belongs under the lead (above Biography).
$('.mw-parser-output .ia-embed[data-archive-id]').each(function () {
var $storyCue = $out.find('.soldier-story-cue').first();
var $slot = $(this);
if ($storyCue.length && !$storyCue.data('rbStoryCuePlaced')) {
if ($slot.data('rbIaEmbed')) {
$storyCue.data('rbStoryCuePlaced', 1);
return;
var $leadCue = $out.find('.soldier-lead, .page-lead').first();
if ($leadCue.length) {
$leadCue.after($storyCue);
}
}
}
var itemId = String($slot.attr('data-archive-id') || '');
 
if (!IA_ID.test(itemId)) {
// Interview / film cue sits with the story cue under the lead.
return;
var $mediaCue = $out.find('.soldier-media-cue').first();
if ($mediaCue.length && !$mediaCue.data('rbMediaCuePlaced')) {
$mediaCue.data('rbMediaCuePlaced', 1);
var $afterStory = $out.find('.soldier-story-cue').first();
if ($afterStory.length) {
$afterStory.after($mediaCue);
} else {
var $leadMedia = $out.find('.soldier-lead, .page-lead').first();
if ($leadMedia.length) {
$leadMedia.after($mediaCue);
}
}
}
}
$slot.data('rbIaEmbed', 1);
 
var title = String($slot.attr('data-title') || 'Internet Archive media');
// Contents box belongs after the lead on soldier/profile pages only.
var playLabel = String($slot.attr('data-play-label') || 'Play interview');
// Other pages (e.g. Site changelog) keep MediaWiki's default TOC placement.
var $fallback = $slot.find('.ia-embed-fallback').first();
var $toc = $out.find('#toc, .toc, .mw-table-of-contents').first();
var $btn = $('<button type="button" class="ia-embed-play"></button>')
if ($toc.length && !$toc.data('rbTocPlaced')) {
.attr('aria-label', playLabel + ' — ' + title)
$toc.data('rbTocPlaced', 1);
.append($('<span class="ia-embed-play-icon" aria-hidden="true"></span>'))
var $lead = $out.find('.soldier-lead, .page-lead, .home-lead').first();
.append($('<span class="ia-embed-play-text"></span>').text(playLabel));
if ($lead.length) {
var $poster = $('<div class="ia-embed-poster"></div>').append($btn);
$lead.after($toc);
if ($fallback.length) {
$fallback.before($poster);
$fallback.hide();
} else {
} else {
var $ibox = $out.find('table.soldier-infobox, table.infobox').first();
$slot.prepend($poster);
if ($ibox.length) {
$ibox.after($toc);
}
}
}
}
$poster.on('click', function (ev) {
ev.preventDefault();
playInternetArchive($slot, itemId, title);
});
});
}());
}());


(function moveSoldierFooterCues() {
(function placeSoldierAwardsSection() {
// Ribbon racks belong in the main column after Biography (like Wikipedia's
// Awards section). Do not use a wiki == heading here — that stole the TOC.
var $out = $('.mw-parser-output').first();
var $out = $('.mw-parser-output').first();
var $awards = $out.find('.soldier-awards-section').first();
if (!$out.length) {
if (!$out.length) {
return;
return;
}
}
var $notes = $out.find('.research-notes-disclaimer').first();
 
var $sources = $out.find('.source-confidence-cue').first();
function sectionAfter($h) {
if (!$notes.length && !$sources.length) {
var $nodes = $h.nextUntil('h2');
return;
if ($nodes.length) {
}
return $nodes.last();
var $footer = $out.find('.soldier-page-footer-notes').first();
}
if (!$footer.length) {
return $h;
$footer = $('<div class="soldier-page-footer-notes"></div>');
$out.append($footer);
}
if ($notes.length) {
$footer.append($notes);
}
if ($sources.length) {
$footer.append($sources);
}
}
}());


var NAME_PARTICLES = {
if ($awards.length && !$awards.data('rbAwardsPlaced')) {
von: 1, van: 1, de: 1, der: 1, den: 1, da: 1, di: 1, du: 1,
$awards.data('rbAwardsPlaced', 1);
la: 1, le: 1, del: 1, della: 1, ter: 1, ten: 1, zu: 1, zum: 1, bin: 1
var $bio = $out.find('h2').filter(function () {
};
var t = $(this).find('.mw-headline').text() || $(this).text();
var NAME_SUFFIX = {
return /^(Biography|Life)\b/i.test($.trim(t));
jr: 'Jr', 'jr.': 'Jr.', sr: 'Sr', 'sr.': 'Sr.',
}).first();
esq: 'Esq', 'esq.': 'Esq.', ii: 'II', iii: 'III', iv: 'IV'
if ($bio.length) {
};
sectionAfter($bio).after($awards);
function lettersOnly(s) {
} else {
return String(s || '').replace(/[^A-Za-z]/g, '');
var $before = $out.find('h2').filter(function () {
}
var t = $(this).find('.mw-headline').text() || $(this).text();
function isAllCaps(s) {
return /^(Gallery|Research notes|Sources|Technical record)\b/i.test($.trim(t));
var letters = lettersOnly(s);
}).first();
return letters.length >= 1 && letters === letters.toUpperCase();
if ($before.length) {
}
$before.before($awards);
function isAllLower(s) {
} else {
var letters = lettersOnly(s);
$out.append($awards);
return letters.length >= 2 && letters === letters.toLowerCase();
}
}
}
function titleCaseWord(s) {
s = String(s || '').toLowerCase();
return s ? s.charAt(0).toUpperCase() + s.slice(1) : s;
}
function prettyCapsWord(word) {
var trail = '';
while (word && /[.,]$/.test(word)) {
trail = word.slice(-1) + trail;
word = word.slice(0, -1);
}
}
var up = lettersOnly(word).toUpperCase();
 
if (up.length >= 5 && up.slice(0, 2) === 'MC') {
// Class/team rail rides with awards (after Biography).
return 'Mc' + titleCaseWord(up.slice(2)) + trail;
var $related = $out.find('.soldier-related').first();
if ($related.length && !$related.data('rbRelatedPlaced')) {
$related.data('rbRelatedPlaced', 1);
var $awardsNow = $out.find('.soldier-awards-section').first();
if ($awardsNow.length) {
$awardsNow.after($related);
} else {
var $bio2 = $out.find('h2').filter(function () {
var t = $(this).find('.mw-headline').text() || $(this).text();
return /^(Biography|Life)\b/i.test($.trim(t));
}).first();
if ($bio2.length) {
sectionAfter($bio2).after($related);
}
}
}
}
if (up.length >= 7 && up.slice(0, 3) === 'MAC') {
 
return 'Mac' + titleCaseWord(up.slice(3)) + trail;
// Story cue belongs under the lead (above Biography).
var $storyCue = $out.find('.soldier-story-cue').first();
if ($storyCue.length && !$storyCue.data('rbStoryCuePlaced')) {
$storyCue.data('rbStoryCuePlaced', 1);
var $leadCue = $out.find('.soldier-lead, .page-lead').first();
if ($leadCue.length) {
$leadCue.after($storyCue);
}
}
}
return titleCaseWord(up) + trail;
 
}
// Interview / film cue sits with the story cue under the lead.
function prettyNameToken(word, leading) {
var $mediaCue = $out.find('.soldier-media-cue').first();
word = $.trim(word);
if ($mediaCue.length && !$mediaCue.data('rbMediaCuePlaced')) {
if (!word) {
$mediaCue.data('rbMediaCuePlaced', 1);
return word;
var $afterStory = $out.find('.soldier-story-cue').first();
if ($afterStory.length) {
$afterStory.after($mediaCue);
} else {
var $leadMedia = $out.find('.soldier-lead, .page-lead').first();
if ($leadMedia.length) {
$leadMedia.after($mediaCue);
}
}
}
}
if (/\d/.test(word)) {
 
return word;
// Contents box belongs after the lead on soldier/profile pages only.
// Other pages (e.g. Site changelog) keep MediaWiki's default TOC placement.
var $toc = $out.find('#toc, .toc, .mw-table-of-contents').first();
if ($toc.length && !$toc.data('rbTocPlaced')) {
$toc.data('rbTocPlaced', 1);
var $lead = $out.find('.soldier-lead, .page-lead, .home-lead').first();
if ($lead.length) {
$lead.after($toc);
} else {
var $ibox = $out.find('table.soldier-infobox, table.infobox').first();
if ($ibox.length) {
$ibox.after($toc);
}
}
}
}
if (word.indexOf('-') !== -1 && word.charAt(0) !== '-' && word.slice(-1) !== '-') {
}());
return $.map(word.split('-'), function (part, i) {
 
return prettyNameToken(part, leading && i === 0);
(function moveSoldierFooterCues() {
}).join('-');
var $out = $('.mw-parser-output').first();
if (!$out.length) {
return;
}
}
var apos = word.indexOf("'") !== -1 ? "'" : (word.indexOf('\u2019') !== -1 ? '\u2019' : '');
var $notes = $out.find('.research-notes-disclaimer').first();
if (apos) {
var $sources = $out.find('.source-confidence-cue').first();
var bits = word.split(apos);
if (!$notes.length && !$sources.length) {
return prettyNameToken(bits[0], leading) + apos + prettyNameToken(bits.slice(1).join(apos), false);
return;
}
}
if (/^(?:[A-Za-z]\.)+$/.test(word)) {
var $footer = $out.find('.soldier-page-footer-notes').first();
return (isAllCaps(word) || isAllLower(word)) ? word.toUpperCase() : word;
if (!$footer.length) {
$footer = $('<div class="soldier-page-footer-notes"></div>');
$out.append($footer);
}
}
var low = word.toLowerCase();
if ($notes.length) {
if (NAME_SUFFIX[low]) {
$footer.append($notes);
return NAME_SUFFIX[low];
}
}
var bare = lettersOnly(word);
if ($sources.length) {
if (bare.length === 1) {
$footer.append($sources);
return bare.toUpperCase() + word.replace(/[A-Za-z]/, '');
}
}
var lowBare = bare.toLowerCase();
}());
if (NAME_SUFFIX[lowBare] && /^[A-Za-z]+\.?$/.test(word)) {
 
var canon = NAME_SUFFIX[lowBare];
var NAME_PARTICLES = {
if (word.slice(-1) === '.' && canon.slice(-1) !== '.') {
von: 1, van: 1, de: 1, der: 1, den: 1, da: 1, di: 1, du: 1,
return canon + '.';
la: 1, le: 1, del: 1, della: 1, ter: 1, ten: 1, zu: 1, zum: 1, bin: 1
}
};
return canon;
var NAME_SUFFIX = {
}
jr: 'Jr', 'jr.': 'Jr.', sr: 'Sr', 'sr.': 'Sr.',
if (NAME_PARTICLES[lowBare] && /^[A-Za-z]+$/.test(word) && (isAllCaps(word) || isAllLower(word))) {
esq: 'Esq', 'esq.': 'Esq.', ii: 'II', iii: 'III', iv: 'IV'
return leading ? titleCaseWord(lowBare) : lowBare;
};
function lettersOnly(s) {
return String(s || '').replace(/[^A-Za-z]/g, '');
}
function isAllCaps(s) {
var letters = lettersOnly(s);
return letters.length >= 1 && letters === letters.toUpperCase();
}
function isAllLower(s) {
var letters = lettersOnly(s);
return letters.length >= 2 && letters === letters.toLowerCase();
}
function titleCaseWord(s) {
s = String(s || '').toLowerCase();
return s ? s.charAt(0).toUpperCase() + s.slice(1) : s;
}
function prettyCapsWord(word) {
var trail = '';
while (word && /[.,]$/.test(word)) {
trail = word.slice(-1) + trail;
word = word.slice(0, -1);
}
}
if (isAllCaps(word) && bare.length >= 2 && bare.length <= 3 && !/[AEIOUY]/i.test(bare)) {
var up = lettersOnly(word).toUpperCase();
return word;
if (up.length >= 5 && up.slice(0, 2) === 'MC') {
return 'Mc' + titleCaseWord(up.slice(2)) + trail;
}
}
if (isAllCaps(word) || isAllLower(word)) {
if (up.length >= 7 && up.slice(0, 3) === 'MAC') {
return prettyCapsWord(word);
return 'Mac' + titleCaseWord(up.slice(3)) + trail;
}
}
return word;
return titleCaseWord(up) + trail;
}
}
function prettyNamePhrase(text, capLead) {
function prettyNameToken(word, leading) {
text = $.trim(text || '').replace(/\s*;\s*/g, ' ');
word = $.trim(word);
if (!text) {
if (!word) {
return '';
return word;
}
}
return $.map(text.split(/\s*,\s*/), function (clause, clauseI) {
if (/\d/.test(word)) {
clause = $.trim(clause);
return word;
if (!clause) {
return null;
}
var words = clause.split(/\s+/);
return $.map(words, function (word, i) {
var particle = lettersOnly(word).toLowerCase();
var leading = (clauseI === 0 && i === 0 && capLead) || words.length === 1;
if (NAME_PARTICLES[particle] && /^[A-Za-z]+$/.test(word) && (isAllCaps(word) || isAllLower(word))) {
return leading ? titleCaseWord(particle) : word.toLowerCase();
}
return prettyNameToken(word, i === 0);
}).join(' ');
}).join(', ');
}
function prettySoldierLabel(text) {
var m = $.trim(text || '').match(/^(.*) \(([^()]+)\)\s*$/);
if (!m || m[1].indexOf('(') !== -1) {
return prettyNamePhrase(text, true);
}
}
return prettyNamePhrase(m[1], true) + ' (' + m[2] + ')';
if (word.indexOf('-') !== -1 && word.charAt(0) !== '-' && word.slice(-1) !== '-') {
}
return $.map(word.split('-'), function (part, i) {
function maybePrettySoldierTitle(label) {
return prettyNameToken(part, leading && i === 0);
var text = $.trim(label || '');
}).join('-');
if (!text || text.indexOf(',') === -1) {
return text;
}
}
var sur = text.split(',')[0];
var apos = word.indexOf("'") !== -1 ? "'" : (word.indexOf('\u2019') !== -1 ? '\u2019' : '');
var letters = lettersOnly(sur);
if (apos) {
if (!(letters.length >= 2 && letters === letters.toUpperCase())) {
var bits = word.split(apos);
return text;
return prettyNameToken(bits[0], leading) + apos + prettyNameToken(bits.slice(1).join(apos), false);
}
if (/^(?:[A-Za-z]\.)+$/.test(word)) {
return (isAllCaps(word) || isAllLower(word)) ? word.toUpperCase() : word;
}
var low = word.toLowerCase();
if (NAME_SUFFIX[low]) {
return NAME_SUFFIX[low];
}
var bare = lettersOnly(word);
if (bare.length === 1) {
return bare.toUpperCase() + word.replace(/[A-Za-z]/, '');
}
}
return prettySoldierLabel(text);
var lowBare = bare.toLowerCase();
}
if (NAME_SUFFIX[lowBare] && /^[A-Za-z]+\.?$/.test(word)) {
function applyPrettySoldierLabels($links) {
var canon = NAME_SUFFIX[lowBare];
$links.each(function () {
if (word.slice(-1) === '.' && canon.slice(-1) !== '.') {
var $a = $(this);
return canon + '.';
var label = $a.text();
var pretty = maybePrettySoldierTitle(label);
if (pretty && pretty !== label) {
$a.text(pretty);
if ($a.attr('title') === label || !$a.attr('title')) {
$a.attr('title', pretty);
}
}
}
});
return canon;
}
if (NAME_PARTICLES[lowBare] && /^[A-Za-z]+$/.test(word) && (isAllCaps(word) || isAllLower(word))) {
return leading ? titleCaseWord(lowBare) : lowBare;
}
if (isAllCaps(word) && bare.length >= 2 && bare.length <= 3 && !/[AEIOUY]/i.test(bare)) {
return word;
}
if (isAllCaps(word) || isAllLower(word)) {
return prettyCapsWord(word);
}
return word;
}
}
if (mw.config.get('wgNamespaceNumber') === 14) {
function prettyNamePhrase(text, capLead) {
applyPrettySoldierLabels($('.mw-category-group li a'));
text = $.trim(text || '').replace(/\s*;\s*/g, ' ');
}
if (!text) {
if (mw.config.get('wgCanonicalSpecialPageName') === 'Search') {
return '';
applyPrettySoldierLabels($('.mw-search-result-heading a'));
}
return $.map(text.split(/\s*,\s*/), function (clause, clauseI) {
clause = $.trim(clause);
if (!clause) {
return null;
}
var words = clause.split(/\s+/);
return $.map(words, function (word, i) {
var particle = lettersOnly(word).toLowerCase();
var leading = (clauseI === 0 && i === 0 && capLead) || words.length === 1;
if (NAME_PARTICLES[particle] && /^[A-Za-z]+$/.test(word) && (isAllCaps(word) || isAllLower(word))) {
return leading ? titleCaseWord(particle) : word.toLowerCase();
}
return prettyNameToken(word, i === 0);
}).join(' ');
}).join(', ');
}
function prettySoldierLabel(text) {
var m = $.trim(text || '').match(/^(.*) \(([^()]+)\)\s*$/);
if (!m || m[1].indexOf('(') !== -1) {
return prettyNamePhrase(text, true);
}
return prettyNamePhrase(m[1], true) + ' (' + m[2] + ')';
}
function maybePrettySoldierTitle(label) {
var text = $.trim(label || '');
if (!text || text.indexOf(',') === -1) {
return text;
}
var sur = text.split(',')[0];
var letters = lettersOnly(sur);
if (!(letters.length >= 2 && letters === letters.toUpperCase())) {
return text;
}
return prettySoldierLabel(text);
}
function applyPrettySoldierLabels($links) {
$links.each(function () {
var $a = $(this);
var label = $a.text();
var pretty = maybePrettySoldierTitle(label);
if (pretty && pretty !== label) {
$a.text(pretty);
if ($a.attr('title') === label || !$a.attr('title')) {
$a.attr('title', pretty);
}
}
});
}
if (mw.config.get('wgNamespaceNumber') === 14) {
applyPrettySoldierLabels($('.mw-category-group li a'));
}
if (mw.config.get('wgCanonicalSpecialPageName') === 'Search') {
applyPrettySoldierLabels($('.mw-search-result-heading a'));
}
}
// Search box suggestions use the real page title; pretty-case ALL-CAPS surnames as they appear.
// Search box suggestions use the real page title; pretty-case ALL-CAPS surnames as they appear.
Line 883: Line 1,074:


function teamHref(name) {
function teamHref(name) {
return '/index.php?title=' + encodeURIComponent('Team ' + name).replace(/%20/g, '_');
return mw.util.getUrl('Team ' + name);
}
}
function teamLink(name, label) {
function teamLink(name, label) {
Line 995: Line 1,186:
}
}


var negative = /fail(?:ed|ure)?|flunk|wash(?:ed)?\s*out|kick(?:ed)?\s*out|dismiss|expel|unsatisfactor|poor\s+(?:performance|work|grades)|awol|court[-\s]?martial|disciplin|discharged\s+for\s+cause|drop(?:ped)?\s+(?:out|from)|relieved|eliminat|unfit|misconduct|desert|excess\s+absence|absence\s*\(|\billness\b|sick\s+call|medical\s+board|psychiatr|neurot|homosexual|suicid|alcohol(?:ism)?|drunk|intoxicat|venereal|\bVD\b|syphili|gonorrh|mental\s+(?:case|illness)|nervous\s+breakdown|not\s+graduat|did\s+not\s+graduate|physically\s+unfit|unfit\s+for|non[-\s]?graduate|certificate\s+listed\s+as\s+ng/i;
var negative = /fail(?:ed|ure)?|flunk|wash(?:ed)?\s*out|kick(?:ed)?\s*out|dismiss|expel|unsatisfactor|poor\s+(?:performance|work|grades)|awol|court[-\s]?martial|disciplin|discharged\s+for\s+cause|drop(?:ped)?\s+(?:out|from)|relieved|eliminat|unfit|misconduct|desert|excess\s+absence|absence\s*\(|\billness\b|sick\s+call|medical\s+board|psychiatr|neurot|homosexual|suicid|alcohol(?:ism)?|drunk|intoxicat|venereal|\bVD\b|syphili|gonorrh|mental\s+(?:case|illness)|nervous\s+breakdown|not\s+graduat|did\s+not\s+graduate|physically\s+unfit|unfit\s+for|non[-\s]?graduate|certificate\s+listed\s+as\s+ng|(?:^|\b)NG\b(?:\s+Class|\s+OB|\s*$|\s*[,;:/]|\s+\d)|\bnongrad/i;
var $ul = $heading.nextAll('ul').first();
var $ul = $heading.nextAll('ul').first();
if (!$ul.length) {
if (!$ul.length) {
Line 1,238: Line 1,429:
{ key: 'death_year', label: 'Death year', hint: 'Four digits, e.g. 2001' },
{ key: 'death_year', label: 'Death year', hint: 'Four digits, e.g. 2001' },
{ key: 'nativity', label: 'Nativity (birthplace)' },
{ key: 'nativity', label: 'Nativity (birthplace)' },
{ key: 'religion', label: 'Religion' },
{ key: 'religion', label: 'Religion' }
{ key: 'marital', label: 'Marital status' },
{ key: 'education', label: 'Education' },
{ key: 'occupation', label: 'Civilian occupation' },
{ key: 'state', label: 'State (residence)' }
]
]
},
},
Line 1,264: Line 1,451:
collapsed: true,
collapsed: true,
fields: [
fields: [
{ key: 'other_class', label: 'Other class' },
{
key: 'other_class',
label: 'Other class',
hint: 'Roster code, e.g. SC8, DC36, MITU4 (shown expanded on the page)'
},
{ key: 'division', label: 'Unit / division' },
{ key: 'division', label: 'Unit / division' },
{ key: 'loc', label: 'LOC' },
{ key: 'loc', label: 'LOC' }
{ key: 'date_arrive', label: 'Date arrive' },
{ key: 'date_depart', label: 'Date depart' }
]
]
}
}
Line 1,274: Line 1,463:


var $box = $('<div>', { 'class': 'soldier-facts-box' });
var $box = $('<div>', { 'class': 'soldier-facts-box' });
var $intro = $('<p>').append(
var $intro = $('<p>', { 'class': 'soldier-facts-intro' });
$('<strong>').text('Editing this page'),
document.createTextNode(' ')
);
$intro.append(
$intro.append(
$('<strong>', { 'class': 'soldier-facts-label' }).text('Editing this page'),
$('<span>', { 'class': 'soldier-facts-help' }).text(' — '),
$('<button>', { type: 'button', 'class': 'soldier-facts-toggle', text: 'Edit soldier facts' }),
$('<button>', { type: 'button', 'class': 'soldier-facts-toggle', text: 'Edit soldier facts' }),
document.createTextNode(' (infobox fields), '),
$('<span>', { 'class': 'soldier-facts-help' }).text(' (infobox fields), '),
$('<a>', { href: editUrl, text: 'Edit biography' }),
$('<a>', { href: editUrl, text: 'Edit biography' }),
document.createTextNode(' (visual editor for Biography and notes), or '),
$('<span>', { 'class': 'soldier-facts-help' }).text(' (visual editor for Biography and notes), or '),
$('<a>', { href: sourceUrl, text: 'Edit source' }),
$('<a>', { href: sourceUrl, text: 'Edit source' }),
document.createTextNode(' (full page markup).')
$('<span>', { 'class': 'soldier-facts-help' }).text(' (full page markup).')
);
);
$box.append($intro);
$box.append($intro);
Line 1,292: Line 1,480:
$draftBar.append(
$draftBar.append(
$draftBtn,
$draftBtn,
document.createTextNode(' — optional 5–10 sentence opening from Personal details and the Biography section. Review before saving.')
$('<span>', { 'class': 'soldier-summary-help' }).text(
' — optional 5–10 sentence opening from Personal details and the Biography section. Review before saving.'
)
);
);
$box.append($draftBar);
$box.append($draftBar);
Line 2,275: Line 2,465:
$panel.append($banner);
$panel.append($banner);
} else {
} else {
var engineBit = job.ocr_engine ? (' · Engine: ' + job.ocr_engine) : '';
$panel.append($('<p>', { 'class': 'ocr-fact-meta' }).text(
$panel.append($('<p>', { 'class': 'ocr-fact-meta' }).text(
(job.doc_type_label || 'Document') +
(job.doc_type_label || 'Document') +
' · OCR quality ' + (job.ocr_quality || 0) + '%' +
' · OCR quality ' + (job.ocr_quality || 0) + '%' +
engineBit +
(job.error ? (' · Error: ' + job.error) : '')
(job.error ? (' · Error: ' + job.error) : '')
));
));
if (job.ocr_engine_notes && job.ocr_engine_notes.length) {
$panel.append($('<p>', { 'class': 'ocr-fact-meta' }).text(
job.ocr_engine_notes.slice(0, 3).join(' · ')
));
}
}
}


Line 2,462: Line 2,659:
if (isMultiPersonJob(job) && !job.bound_title) {
if (isMultiPersonJob(job) && !job.bound_title) {
$forceMulti.hide();
$forceMulti.hide();
}
var $forceVision = $('<label>', { 'class': 'ocr-force-vision' }).append(
$('<input>', { type: 'checkbox', checked: mwApiBool(job.force_vision) }),
' Handwriting / hard scan (print OCR first; paid vision only if weak)'
);
if (!cfg.llmVision) {
$forceVision.append(document.createTextNode(' — needs RITCHIE_LLM_API_KEY on OCR service'));
}
}
var $forceId = $('<label>', { 'class': 'ocr-force-identity' }).append(
var $forceId = $('<label>', { 'class': 'ocr-force-identity' }).append(
Line 2,476: Line 2,680:
$applyNotes, $('<br>'),
$applyNotes, $('<br>'),
$forceMulti, $('<br>'),
$forceMulti, $('<br>'),
$forceVision, $('<br>'),
$forceId
$forceId
);
);


var $save = $('<button>', { type: 'button', text: 'Save draft' });
var $save = $('<button>', { type: 'button', text: 'Save draft' });
var $rebuild = $('<button>', { type: 'button', text: 'Rebuild from pages / roster mode' });
var $rebuild = $('<button>', { type: 'button', text: 'Rebuild facts / re-OCR if handwriting checked' });
if (pages.length < 2 && !job.bound_title) {
$rebuild.hide();
}
$advBody.append($('<div>', { 'class': 'ocr-advanced-actions' }).append($save, $rebuild));
$advBody.append($('<div>', { 'class': 'ocr-advanced-actions' }).append($save, $rebuild));
$adv.append($advBody);
$adv.append($advBody);
Line 2,567: Line 2,769:
force_identity: $forceId.find('input').prop('checked'),
force_identity: $forceId.find('input').prop('checked'),
force_multi: $forceMulti.find('input').prop('checked'),
force_multi: $forceMulti.find('input').prop('checked'),
force_vision: $forceVision.find('input').prop('checked'),
apply_notes: $applyNotes.find('input').prop('checked')
apply_notes: $applyNotes.find('input').prop('checked')
};
};
Line 2,769: Line 2,972:
bound_title: boundTitle || '',
bound_title: boundTitle || '',
force_multi: opts.forceMulti ? true : false,
force_multi: opts.forceMulti ? true : false,
force_vision: opts.forceVision ? true : false,
file: file,
file: file,
format: 'json'
format: 'json'
Line 2,815: Line 3,019:
var $section = $('<div>', { 'class': 'soldier-contrib-section soldier-contrib-ocr' });
var $section = $('<div>', { 'class': 'soldier-contrib-section soldier-contrib-ocr' });
$section.append($('<p>', { 'class': 'soldier-contrib-heading' }).append(
$section.append($('<p>', { 'class': 'soldier-contrib-heading' }).append(
$('<strong>').text('OCR a document')
$('<strong>').text('OCR a document to enrich this profile')
));
));
$section.append($('<p>', { 'class': 'soldier-contrib-help' }).text(
$section.append($('<p>', { 'class': 'soldier-contrib-help' }).text(
'Scan or photo of a form, letter, or roster. Proposed facts appear here for review — nothing is written until Apply.'
'Scan a form, letter, certificate, clipping, or roster. Review proposes infobox facts and a short biography draft — nothing is written until Apply. Team rosters can update many soldiers at once.'
));
));
var $file = $('<input>', {
var $file = $('<input>', {
Line 2,828: Line 3,032:
$('<input>', { type: 'checkbox' }),
$('<input>', { type: 'checkbox' }),
' This is a multi-person roster (not only this soldier)'
' This is a multi-person roster (not only this soldier)'
);
var $visionMode = $('<label>', { 'class': 'ocr-force-multi-upload' }).append(
$('<input>', { type: 'checkbox' }),
' Handwriting / hard scan (free print OCR first; vision only if needed)'
);
);
var $btn = $('<button>', { type: 'button', text: 'Upload & OCR' });
var $btn = $('<button>', { type: 'button', text: 'Upload & OCR' });
var $host = $('<div>', { 'class': 'ocr-review-host' });
var $host = $('<div>', { 'class': 'ocr-review-host' });
$section.append($file, $rosterMode, $btn, $host);
$section.append($file, $rosterMode, $visionMode, $btn, $host);
$btn.on('click', function () {
$btn.on('click', function () {
startUpload($file, pageTitle, $host, cfg, {
startUpload($file, pageTitle, $host, cfg, {
forceMulti: $rosterMode.find('input').prop('checked')
forceMulti: $rosterMode.find('input').prop('checked'),
forceVision: $visionMode.find('input').prop('checked')
});
});
});
});
Line 2,857: Line 3,066:
$up.append($('<h3>').text('Upload a document'));
$up.append($('<h3>').text('Upload a document'));
$up.append($('<p>').text(
$up.append($('<p>').text(
'For a roster, review is a table: who is on the scan, which wiki page, and what would be written on that profile (usually Team). Rank, role, and ASN are matching cues, not automatic edits.'
'Enrich soldier profiles from scans: propose infobox facts, a short biography draft, and Sources. For multi-person IPW rosters, review is a table (usually Team membership). Rank, role, and ASN are matching cues until you approve.'
));
));
var $file2 = $('<input>', { type: 'file', accept: '.jpg,.jpeg,.png,.gif,.webp,.tif,.tiff,.pdf' });
var $file2 = $('<input>', { type: 'file', accept: '.jpg,.jpeg,.png,.gif,.webp,.tif,.tiff,.pdf' });
var $vision2 = $('<label>', { 'class': 'ocr-force-multi-upload' }).append(
$('<input>', { type: 'checkbox' }),
' Handwriting / hard scan (vision only if print OCR is weak)'
);
var $btn2 = $('<button>', { type: 'button', text: 'Upload & OCR' });
var $btn2 = $('<button>', { type: 'button', text: 'Upload & OCR' });
var $host2 = $('<div>', { 'class': 'ocr-review-host' });
var $host2 = $('<div>', { 'class': 'ocr-review-host' });
$up.append($file2, ' ', $btn2, $host2);
$up.append($file2, $vision2, ' ', $btn2, $host2);
$btn2.on('click', function () {
$btn2.on('click', function () {
startUpload($file2, '', $host2, cfg);
startUpload($file2, '', $host2, cfg, {
forceVision: $vision2.find('input').prop('checked')
});
});
});
$slot.append($up);
$slot.append($up);
Line 3,107: Line 3,322:
var $block = $(this);
var $block = $(this);
var cat = String($block.attr('data-team-cat') || '');
var cat = String($block.attr('data-team-cat') || '');
if (!/^Category:Team /.test(cat)) {
if (!/^Category:Team /.test(cat)) {
return;
return;
}
}
var $links = $block.find('.soldier-related-links-live').first();
var $links = $block.find('.soldier-related-links-live').first();
if (!$links.length) {
if (!$links.length) {
return;
return;
}
}
$links.text('Loading teammates…');
$links.text('Loading teammates…');
fetchCategoryMembers(cat).done(function (titles) {
fetchCategoryMembers(cat).done(function (titles) {
var others = titles.filter(function (t) {
var others = titles.filter(function (t) {
return t.replace(/_/g, ' ') !== current;
return t.replace(/_/g, ' ') !== current;
});
});
others.sort(function (a, b) {
others.sort(function (a, b) {
return titleToCaption(a).localeCompare(titleToCaption(b));
return titleToCaption(a).localeCompare(titleToCaption(b));
});
});
$links.empty();
$links.empty();
if (!others.length) {
if (!others.length) {
$links.append(document.createTextNode('No other teammates listed yet · '));
$links.append(document.createTextNode('No other teammates listed yet · '));
$links.append(wikiLink(cat, 'full roster'));
$links.append(wikiLink(cat, 'full roster'));
return;
return;
}
}
var max = 24;
var max = 24;
others.slice(0, max).forEach(function (title, i) {
others.slice(0, max).forEach(function (title, i) {
if (i > 0) {
if (i > 0) {
$links.append(document.createTextNode(' · '));
$links.append(document.createTextNode(' · '));
}
}
$links.append(wikiLink(title, titleToCaption(title)));
$links.append(wikiLink(title, titleToCaption(title)));
});
});
$links.append(document.createTextNode(' · '));
$links.append(document.createTextNode(' · '));
$links.append(wikiLink(cat, others.length > max ? 'full roster (' + others.length + ')' : 'full roster'));
$links.append(wikiLink(cat, others.length > max ? 'full roster (' + others.length + ')' : 'full roster'));
}).fail(function () {
}).fail(function () {
$links.empty().append(wikiLink(cat, 'Browse team roster'));
$links.empty().append(wikiLink(cat, 'Browse team roster'));
});
});
});
});
});
});
}());
}());
 
$(function rbCampManualUpload() {
var $slot = $('.mw-parser-output .camp-manual-upload-slot').first();
if (!$slot.length || $slot.data('rbCampManualUpload')) {
return;
}
$slot.data('rbCampManualUpload', 1);
 
var LIBRARY = 'Training materials/Library';
var PLACEHOLDER = "''No manuals listed yet. Upload a PDF above and it will appear here.''";
var DOC_EXT = /\.(pdf|docx?|txt|rtf|odt|tiff?|jpe?g|png|gif|webp)$/i;
var pageTitle = mw.config.get('wgPageName');
var loggedIn = !!mw.config.get('wgUserName');
var uploadUrl = mw.util.getUrl('Special:Upload');
 
$slot.empty();
var $box = $('<div>', { 'class': 'camp-manual-upload-box soldier-upload-body' });
$box.append($('<p>', { 'class': 'soldier-contrib-help' }).append(
document.createTextNode('Upload a manual or related Camp Ritchie document (PDF preferred). Also '),
$('<a>', { href: uploadUrl, text: 'Special:Upload' }),
document.createTextNode('.')
));
 
if (!loggedIn) {
$box.append($('<p>', { 'class': 'soldier-upload-login' }).html(
'<a href="' + mw.util.getUrl('Special:UserLogin', { returnto: pageTitle }) + '">Log in</a> to upload files.'
));
$slot.append($box);
return;
}
 
var $form = $('<form>', { 'class': 'soldier-upload-form camp-manual-upload-form' });
var $file = $('<input>', {
type: 'file',
name: 'campManualFile',
'class': 'soldier-upload-file',
accept: '.pdf,.doc,.docx,.txt,.rtf,.odt,.tif,.tiff,.jpg,.jpeg,.png'
});
var $label = $('<label>').append(
document.createTextNode('Display title (optional)'),
$('<input>', {
type: 'text',
name: 'campManualTitle',
'class': 'camp-manual-title',
placeholder: 'e.g. Interrogation manual, 1943',
maxlength: 200
})
);
var $note = $('<label>').append(
document.createTextNode('Short note / source (optional)'),
$('<input>', {
type: 'text',
name: 'campManualNote',
'class': 'camp-manual-note',
placeholder: 'e.g. NARA / museum scan',
maxlength: 300
})
);
var $status = $('<p>', { 'class': 'soldier-upload-status', text: '' });
var $progress = $('<div>', { 'class': 'camp-manual-progress', hidden: true });
var $progressFill = $('<div>', { 'class': 'camp-manual-progress-fill' });
var $progressBar = $('<div>', {
'class': 'camp-manual-progress-bar',
role: 'progressbar',
'aria-valuemin': 0,
'aria-valuemax': 100,
'aria-valuenow': 0
}).append($progressFill);
var $progressLabel = $('<p>', { 'class': 'camp-manual-progress-label', text: '' });
$progress.append($progressBar, $progressLabel);
var $submit = $('<button>', { type: 'submit', text: 'Upload to library' });
$form.append($file, $label, $note, $submit, $progress, $status);
$box.append($form);
$slot.append($box);
 
function setStatus(msg, isError) {
$status.text(msg).css('color', isError ? '#ba0000' : '#202122');
}
 
function formatBytes(n) {
if (n >= 1024 * 1024) {
return (n / (1024 * 1024)).toFixed(1) + ' MB';
}
if (n >= 1024) {
return Math.round(n / 1024) + ' KB';
}
return n + ' B';
}
 
function setProgress(loaded, total) {
$progress.prop('hidden', false);
if (!total) {
$progress.addClass('is-indeterminate');
$progressBar.attr('aria-valuenow', 0);
$progressFill.css('width', '30%');
$progressLabel.text('Uploading…');
return;
}
$progress.removeClass('is-indeterminate');
var pct = Math.max(0, Math.min(100, Math.round((loaded / total) * 100)));
$progressBar.attr('aria-valuenow', pct);
$progressFill.css('width', pct + '%');
$progressLabel.text(pct + '% · ' + formatBytes(loaded) + ' of ' + formatBytes(total));
}
 
function resetProgress() {
$progress.prop('hidden', true).removeClass('is-indeterminate');
$progressFill.css('width', '0%');
$progressBar.attr('aria-valuenow', 0);
$progressLabel.text('');
}
 
function escapePipe(s) {
return String(s || '').replace(/\|/g, '–').replace(/\[\[/g, '').replace(/\]\]/g, '');
}
 
function buildBullet(filename, displayTitle, note) {
var label = escapePipe(displayTitle || filename.replace(/_/g, ' '));
var line = '* [[Media:' + filename + '|' + label + ']]';
if (note) {
line += ' — ' + escapePipe(note);
}
return line;
}
 
function appendToLibrary(api, filename, displayTitle, note) {
var bullet = buildBullet(filename, displayTitle, note);
return api.get({
action: 'query',
prop: 'revisions|info',
titles: LIBRARY,
rvprop: 'content|timestamp',
rvslots: 'main',
curtimestamp: 1,
formatversion: 2,
maxage: 0,
smaxage: 0
}).then(function (data) {
var page = data.query && data.query.pages && data.query.pages[0];
var rev = page && page.revisions && page.revisions[0];
var text = (rev && rev.slots && rev.slots.main && rev.slots.main.content) || '';
var basets = rev && rev.timestamp;
var startts = data.curtimestamp;
var next;
var trimmed = $.trim(text);
var already = trimmed.indexOf('[[Media:' + filename + '|') !== -1 ||
trimmed.indexOf('[[File:' + filename + '|') !== -1;
if (already) {
return purgeLibraryPages(api).then(function () {
return { edit: { result: 'Success', nochange: true } };
});
}
if (!trimmed || trimmed.indexOf(PLACEHOLDER) !== -1) {
next = bullet + '\n';
} else {
next = trimmed + '\n' + bullet + '\n';
}
var params = {
action: 'edit',
title: LIBRARY,
text: next,
summary: 'Add training material: ' + filename,
format: 'json'
};
if (basets) {
params.basetimestamp = basets;
}
if (startts) {
params.starttimestamp = startts;
}
return api.postWithToken('csrf', params).then(function (editData) {
if (editData.edit && editData.edit.result === 'Success') {
return purgeLibraryPages(api).then(function () {
return editData;
});
}
if (editData.error && editData.error.code === 'editconflict') {
return appendToLibrary(api, filename, displayTitle, note);
}
throw new Error((editData.error && editData.error.info) || 'Could not update library list');
});
});
}
 
function failMessage(code, data) {
if (data && data.error && data.error.info) {
return data.error.info;
}
if (data && data.exception && data.exception.message) {
return data.exception.message;
}
if (code && typeof code === 'object' && code.message) {
return code.message;
}
if (typeof code === 'string' && code && code !== 'http' && code !== 'error' && code !== 'timeout') {
return code;
}
if (data && data.xhr && data.xhr.status) {
return 'Upload failed (HTTP ' + data.xhr.status + '). Try again, or use Special:Upload.';
}
return 'Upload failed (connection dropped or timed out). Try again, or use Special:Upload.';
}
 
function purgeLibraryPages(api) {
return api.postWithToken('csrf', {
action: 'purge',
titles: 'Training materials|' + LIBRARY,
forcelinkupdate: 1,
format: 'json'
});
}
 
function readLibraryText(api) {
return api.get({
action: 'query',
prop: 'revisions|info',
titles: LIBRARY,
rvprop: 'content|timestamp',
rvslots: 'main',
curtimestamp: 1,
formatversion: 2,
maxage: 0,
smaxage: 0
}).then(function (data) {
var page = data.query && data.query.pages && data.query.pages[0];
var rev = page && page.revisions && page.revisions[0];
return {
text: (rev && rev.slots && rev.slots.main && rev.slots.main.content) || '',
basetimestamp: rev && rev.timestamp,
starttimestamp: data.curtimestamp
};
});
}
 
function writeLibraryText(api, next, summary, basets, startts) {
var params = {
action: 'edit',
title: LIBRARY,
text: next,
summary: summary,
format: 'json'
};
if (basets) {
params.basetimestamp = basets;
}
if (startts) {
params.starttimestamp = startts;
}
return api.postWithToken('csrf', params).then(function (editData) {
if (editData.edit && editData.edit.result === 'Success') {
return purgeLibraryPages(api).then(function () {
return editData;
});
}
if (editData.error && editData.error.code === 'editconflict') {
throw new Error('editconflict');
}
throw new Error((editData.error && editData.error.info) || 'Could not update library list');
});
}
 
function removeFromLibrary(api, filename) {
return readLibraryText(api).then(function (cur) {
var lines = String(cur.text || '').split(/\r?\n/);
var nextLines = lines.filter(function (line) {
return line.indexOf('[[Media:' + filename + '|') === -1 &&
line.indexOf('[[File:' + filename + '|') === -1 &&
line.indexOf(filename) === -1;
});
var next = $.trim(nextLines.join('\n'));
if (!next) {
next = PLACEHOLDER;
} else {
next = next + '\n';
}
return writeLibraryText(
api,
next,
'Remove from Training materials library: ' + filename,
cur.basetimestamp,
cur.starttimestamp
).then(null, function (err) {
if (String(err && err.message || err) === 'editconflict') {
return removeFromLibrary(api, filename);
}
throw err;
});
});
}
 
function deleteLibraryFile(api, filename) {
return api.postWithToken('csrf', {
action: 'delete',
title: 'File:' + filename,
reason: 'Removed from Training materials library',
format: 'json'
}).then(function (data) {
if (data.error) {
throw new Error(data.error.info || 'Could not delete file');
}
return removeFromLibrary(api, filename);
});
}
 
function filenameFromHref(href, titleAttr) {
if (titleAttr && /\.[a-z0-9]{2,5}$/i.test(titleAttr)) {
return String(titleAttr).replace(/ /g, '_');
}
if (!href) {
return '';
}
try {
href = decodeURIComponent(href);
} catch (e) { /* ignore */ }
var m = href.match(/\/wiki\/(?:File|Media):([^?#]+)/i) ||
href.match(/[?&]title=(?:File|Media):([^&#]+)/i) ||
href.match(/\/images\/(?:thumb\/)?[^/]+\/[^/]+\/([^/?#]+)$/i);
if (!m) {
return '';
}
return m[1].replace(/_/g, ' ').trim().replace(/ /g, '_');
}
 
function enhanceLibraryManage() {
if (!mw.config.get('wgUserName')) {
return;
}
var $heading = $('.mw-parser-output .mw-heading, .mw-parser-output h2').filter(function () {
return /^Library\b/i.test($(this).text().replace(/\s*\[.*$/, '').trim());
}).first();
if (!$heading.length) {
return;
}
var $list = $heading.nextAll('ul').first();
if (!$list.length) {
$list = $heading.parent().nextAll('ul').first();
}
if (!$list.length || $list.data('rbCampManualManage')) {
return;
}
$list.data('rbCampManualManage', 1);
 
var groups = mw.config.get('wgUserGroups') || [];
var canDeleteFile = groups.indexOf('sysop') !== -1;
var api = new mw.Api();
 
$list.children('li').each(function () {
var $li = $(this);
if ($li.find('.camp-manual-lib-actions').length) {
return;
}
var $a = $li.find('a.internal, a[href*="/images/"], a[href*="File:"], a[href*="Media:"]').first();
if (!$a.length) {
$a = $li.find('a[href]').first();
}
var filename = filenameFromHref($a.attr('href'), $a.attr('title'));
if (!filename) {
return;
}
var $actions = $('<span>', { 'class': 'camp-manual-lib-actions' });
var $remove = $('<button>', {
type: 'button',
'class': 'camp-manual-lib-remove',
text: 'Remove'
}).attr('title', 'Remove from this list (file stays on the wiki)');
$actions.append($remove);
if (canDeleteFile) {
$actions.append($('<button>', {
type: 'button',
'class': 'camp-manual-lib-delete',
text: 'Delete file'
}).attr('title', 'Delete the file from the wiki (administrators only)'));
}
$li.append($actions);
 
$remove.on('click', function () {
if (!window.confirm('Remove “' + filename.replace(/_/g, ' ') + '” from the Library list?\n(The file will remain on the wiki.)')) {
return;
}
$actions.find('button').prop('disabled', true);
removeFromLibrary(api, filename).done(function () {
window.location.reload();
}).fail(function (code, data) {
window.alert(failMessage(code, data));
$actions.find('button').prop('disabled', false);
});
});
 
$actions.on('click', '.camp-manual-lib-delete', function () {
if (!window.confirm('Permanently delete file “' + filename.replace(/_/g, ' ') + '” from the wiki?')) {
return;
}
$actions.find('button').prop('disabled', true);
deleteLibraryFile(api, filename).done(function () {
window.location.reload();
}).fail(function (code, data) {
window.alert(failMessage(code, data));
$actions.find('button').prop('disabled', false);
});
});
});
}
 
enhanceLibraryManage();
 
$form.on('submit', function (evt) {
evt.preventDefault();
var file = $file[0].files[0];
if (!file) {
setStatus('Choose a file first.', true);
return;
}
if (!DOC_EXT.test(file.name)) {
setStatus('File type not allowed (PDF, Office docs, images, TIFF).', true);
return;
}
var maxBytes = 120 * 1024 * 1024;
if (file.size > maxBytes) {
setStatus('File is too large (max about 120 MB). Split the PDF or compress it, then try again.', true);
return;
}
var displayTitle = $.trim($form.find('.camp-manual-title').val() || '');
var note = $.trim($form.find('.camp-manual-note').val() || '');
$submit.prop('disabled', true);
resetProgress();
setProgress(0, file.size);
setStatus('Uploading…');
 
var api = new mw.Api({ ajax: { timeout: 0 } });
var fileText = 'Camp Ritchie / MITC training material.';
var uploadedName = null;
var uploadParams = {
filename: file.name,
comment: 'Uploaded to Training materials library',
text: fileText,
ignorewarnings: true
};
 
function doUpload() {
/* Multipart + XHR progress (api.upload alone often has no progress events). */
return api.postWithToken('csrf', $.extend({
action: 'upload',
file: file,
format: 'json'
}, uploadParams), {
contentType: 'multipart/form-data',
timeout: 0,
xhr: function () {
var xhr = $.ajaxSettings.xhr();
if (xhr.upload) {
xhr.upload.addEventListener('progress', function (ev) {
if (ev.lengthComputable) {
setProgress(ev.loaded, ev.total);
}
});
}
return xhr;
}
});
}
 
doUpload().then(function (data) {
uploadedName = data.upload && data.upload.filename;
if (!uploadedName) {
throw new Error((data.error && data.error.info) || 'Upload failed');
}
setProgress(file.size, file.size);
$progress.addClass('is-indeterminate');
$progressLabel.text('Finishing…');
setStatus('Adding to Library list…');
return appendToLibrary(api, uploadedName, displayTitle, note);
}).done(function () {
$progress.removeClass('is-indeterminate');
$progressLabel.text('100% · done');
setStatus('Done. Reloading…');
window.location.reload();
}).fail(function (code, data) {
var err = failMessage(code, data);
if (uploadedName) {
err = 'File uploaded as “' + uploadedName + '”, but the Library list was not updated: ' + err +
' Refresh this page; if it is missing from the list, say so and we can add it.';
}
setStatus(String(err), true);
resetProgress();
$submit.prop('disabled', false);
});
});
});


(function rbBooksFilters() {
(function rbBooksFilters() {
Line 3,182: Line 3,885:
}());
}());


 
/* Related-media wrap removed: story/interview cues already sit under the lead
(function rbRelatedMediaWrap() {
* with their own labels (Full story, etc.). Wrapping them added a stray
var $out = $('.mw-parser-output').first();
* Related media heading near the top of the article. */
if (!$out.length) return;
var $cues = $out.find('.soldier-story-cue, .soldier-media-cue');
if ($cues.length < 1) return;
if ($out.find('.soldier-related-media').length) return;
var $wrap = $('<div class="soldier-related-media"><div class="soldier-related-media-title">Related media</div></div>');
$cues.first().before($wrap);
$cues.appendTo($wrap);
}());

Latest revision as of 01:40, 17 September 2026

/**
 * Hide leftover imported rows the infobox no longer shows.
 * Title-case ALL-CAPS soldier names in category lists (native lists use the real page title).
 * Move research-notes and sources-confidence cues to the bottom of soldier pages.
 * Split mashed CSV notes into separate bullets, then link MII/IPW team codes.
 * Build the Main Page find-a-soldier search form (raw HTML forms are stripped from wikitext)
 * with live title suggestions (same idea as the header search box).
 * Reorder Special:Search hits so title matches and site pages rank above research-notes noise.
 * Hide retired “(merged duplicate)” roster pages from find/search suggestions and results.
 * Fill soldier Teammates rails from live Category:Team … membership (no roster-module rebuild).
 * Inject site hub links into the Minerva hamburger (Sidebar is not used by Minerva 1.43).
 */
function rbIsMergedDuplicateTitle(title) {
	return /\(merged duplicate\)\s*$/i.test(String(title || ''));
}

$(function () {
	(function injectMinervaSiteMenu() {
		if (mw.config.get('skin') !== 'minerva') {
			return;
		}
		var $drawer = $('#mw-mf-page-left');
		var $after = $('#p-navigation');
		if (!$drawer.length || !$after.length || $drawer.data('rbSiteMenu')) {
			return;
		}
		$drawer.data('rbSiteMenu', 1);
		var links = [
			{ title: 'Contents', label: 'Contents', icon: 'listBullet' },
			{ title: 'Start here', label: 'Start here', icon: 'lightbulb' },
			{ title: 'Introduction', label: 'Introduction', icon: 'book' },
			{ title: 'The Stories', label: 'Stories', icon: 'newspaper' },
			{ title: 'Category:Classes', label: 'Classes', icon: 'userGroup' },
			{ title: 'Photos', label: 'Photos', icon: 'imageGallery' },
			{ title: 'Training materials', label: 'Training materials', icon: 'articles' },
			{ title: 'Contribute', label: 'Contribute', icon: 'edit' },
			{ title: 'Contact Us', label: 'Contact Us', icon: 'speechBubbles' },
			{ title: 'About', label: 'About', icon: 'infoFilled' }
		];
		var $ul = $('<ul id="p-ritchie" class="toggle-list__list"></ul>');
		links.forEach(function (item) {
			var name = 'rb-' + String(item.title).toLowerCase().replace(/[^a-z0-9]+/g, '-');
			var $a = $('<a class="toggle-list-item__anchor"></a>')
				.addClass('menu__item--' + name)
				.attr('href', mw.util.getUrl(item.title));
			$a.append($('<span class="minerva-icon"></span>').addClass('minerva-icon--' + item.icon));
			$a.append($('<span class="toggle-list-item__label"></span>').text(item.label));
			$ul.append($('<li class="toggle-list-item"></li>').append($a));
		});
		$after.after($ul);
	}());

	(function buildHomeFind() {
		var $slot = $('.mw-parser-output .home-find-slot').first();
		if (!$slot.length || $slot.data('rbHomeFind')) {
			return;
		}
		$slot.data('rbHomeFind', 1);
		var script = $slot.attr('data-search-script') || mw.util.wikiScript();
		$slot.find('.home-find-fallback').remove();
		var $form = $('<form class="home-find-form" method="get"></form>').attr('action', script);
		$form.append($('<input type="hidden" name="title" value="Special:Search" />'));
		$form.append($('<label class="home-find-label" for="home-search"></label>').text('Name or ASN'));
		var $wrap = $('<div class="home-find-suggest-wrap"></div>');
		var $row = $('<div class="home-find-row"></div>');
		var $input = $('<input type="search" name="search" id="home-search" class="home-find-input" autocomplete="off" aria-autocomplete="list" aria-controls="home-find-suggest" />')
			.attr('placeholder', 'e.g. Guy Stern or 37413105');
		var $menu = $('<ul id="home-find-suggest" class="home-find-suggest" role="listbox" hidden></ul>').css({
			position: 'absolute',
			left: 0,
			right: 0,
			top: '100%',
			zIndex: 50,
			margin: '0.15em 0 0',
			padding: '0.2em 0',
			listStyle: 'none',
			background: '#fff',
			border: '1px solid #a2a9b1',
			boxShadow: '0 2px 8px rgba(0,0,0,0.12)',
			maxHeight: '16em',
			overflowY: 'auto'
		});
		$wrap.css({ position: 'relative' });
		$row.append($input);
		$row.append($('<button type="submit" class="home-find-button"></button>').text('Search'));
		$wrap.append($row).append($menu);
		$form.append($wrap);
		$slot.find('.home-find-title').after($form);

		var timer = null;
		var reqId = 0;
		var active = -1;
		var titles = [];
		var soldierTitle = /\(\d{5,9}\)\s*$/;

		function hideSuggest() {
			$menu.attr('hidden', 'hidden').empty();
			active = -1;
			titles = [];
			$input.attr('aria-expanded', 'false');
		}

		function goTitle(title) {
			if (!title) {
				return;
			}
			window.location.href = mw.util.getUrl(title);
		}

		function setActive(idx) {
			var $items = $menu.children('li');
			if (!$items.length) {
				active = -1;
				return;
			}
			if (idx < 0) {
				idx = $items.length - 1;
			}
			if (idx >= $items.length) {
				idx = 0;
			}
			active = idx;
			$items.css('background', '').removeClass('home-find-suggest-active').attr('aria-selected', 'false');
			$items.eq(active).css('background', '#eaf3ff').addClass('home-find-suggest-active').attr('aria-selected', 'true');
		}

		function render(list) {
			titles = list || [];
			$menu.empty();
			if (!titles.length) {
				hideSuggest();
				return;
			}
			titles.forEach(function (title, i) {
				var $li = $('<li role="option"></li>')
					.attr('id', 'home-find-suggest-' + i)
					.css({ margin: 0, padding: '0.4em 0.65em', cursor: 'pointer', fontSize: '95%', lineHeight: 1.35 })
					.text(title)
					.on('mouseenter', function () {
						setActive(i);
					})
					.on('mousedown', function (e) {
						e.preventDefault();
						goTitle(title);
					});
				$menu.append($li);
			});
			$menu.removeAttr('hidden');
			$input.attr('aria-expanded', 'true');
			active = -1;
		}

		function rankTitles(query, hits) {
			var q = String(query || '').trim().toLowerCase();
			var scored = (hits || []).filter(function (h) {
				return !rbIsMergedDuplicateTitle(h.title || '');
			}).map(function (h) {
				var t = h.title || '';
				var low = t.toLowerCase();
				var score = 0;
				if (soldierTitle.test(t)) {
					score += 50;
				}
				if (low === q) {
					score += 100;
				}
				if (low.indexOf(q) === 0) {
					score += 40;
				}
				if (low.indexOf(q) !== -1) {
					score += 20;
				}
				if (q && low.indexOf('(' + q + ')') !== -1) {
					score += 80;
				}
				return { title: t, score: score };
			});
			scored.sort(function (a, b) {
				return b.score - a.score;
			});
			var seen = {};
			var out = [];
			scored.forEach(function (row) {
				if (seen[row.title]) {
					return;
				}
				seen[row.title] = 1;
				out.push(row.title);
			});
			return out.slice(0, 8);
		}

		function fetchSuggest(query) {
			var q = String(query || '').trim();
			if (q.length < 2) {
				hideSuggest();
				return;
			}
			var myId = ++reqId;
			new mw.Api().get({
				action: 'query',
				list: 'search',
				srsearch: q,
				srnamespace: 0,
				srlimit: 12,
				srwhat: 'title'
			}).done(function (data) {
				if (myId !== reqId) {
					return;
				}
				if (String($input.val() || '').trim() !== q) {
					return;
				}
				var hits = (((data || {}).query) || {}).search || [];
				render(rankTitles(q, hits));
			}).fail(function () {
				if (myId === reqId) {
					hideSuggest();
				}
			});
		}

		$input.on('input keyup', function (e) {
			if (e.type === 'keyup' && (e.key === 'ArrowDown' || e.key === 'ArrowUp' || e.key === 'Enter' || e.key === 'Escape')) {
				return;
			}
			window.clearTimeout(timer);
			timer = window.setTimeout(function () {
				fetchSuggest($input.val());
			}, 180);
		});

		$input.on('keydown', function (e) {
			var key = e.key;
			if ($menu.attr('hidden') !== undefined && $menu.is('[hidden]')) {
				if (key === 'ArrowDown' && String($input.val() || '').trim().length >= 2) {
					fetchSuggest($input.val());
				}
				return;
			}
			if (key === 'ArrowDown') {
				e.preventDefault();
				setActive(active + 1);
			} else if (key === 'ArrowUp') {
				e.preventDefault();
				setActive(active - 1);
			} else if (key === 'Escape') {
				hideSuggest();
			} else if (key === 'Enter' && active >= 0 && titles[active]) {
				e.preventDefault();
				goTitle(titles[active]);
			}
		});

		$input.on('blur', function () {
			window.setTimeout(hideSuggest, 150);
		});

		$form.on('submit', function () {
			if (active >= 0 && titles[active]) {
				goTitle(titles[active]);
				return false;
			}
			return true;
		});
	}());

	(function boostSearchResults() {
		if (mw.config.get('wgCanonicalSpecialPageName') !== 'Search') {
			return;
		}
		var $list = $('.mw-search-results').first();
		if (!$list.length || $list.data('rbSearchBoost')) {
			return;
		}
		$list.data('rbSearchBoost', 1);

		var params = new URLSearchParams(window.location.search);
		var raw = String(params.get('search') || '').trim();
		if (!raw) {
			return;
		}
		var cleaned = raw
			.replace(/\b(intitle|incategory|prefix|deepcat)\s*:/gi, ' ')
			.replace(/["']/g, ' ')
			.replace(/\s+/g, ' ')
			.trim()
			.toLowerCase();
		var tokens = cleaned.split(' ').filter(function (t) {
			return t.length > 1;
		});
		if (!tokens.length) {
			return;
		}

		function titleFromItem(el) {
			var $a = $(el).find('.mw-search-result-heading a, a').first();
			return $.trim($a.text() || '');
		}

		function scoreTitle(title) {
			var t = title.toLowerCase();
			var score = 0;
			var i;
			if (t === cleaned) {
				score += 200;
			}
			var allInTitle = true;
			for (i = 0; i < tokens.length; i++) {
				if (t.indexOf(tokens[i]) === -1) {
					allInTitle = false;
					break;
				}
			}
			if (allInTitle) {
				score += 80;
			}
			// Soldier titles: "Surname, Given (ASN)" — reward surname/ASN hits in the title.
			if (/^\d{5,9}$/.test(cleaned) && t.indexOf(cleaned) !== -1) {
				score += 120;
			}
			if (/\(\d{5,9}\)\s*$/.test(title)) {
				score += 5;
			}
			return score;
		}

		function resort(siteTitles) {
			var items = $list.children('li').get();
			items.forEach(function (el) {
				if (rbIsMergedDuplicateTitle(titleFromItem(el))) {
					$(el).remove();
				}
			});
			items = $list.children('li').get();
			if (items.length < 2) {
				return;
			}
			items.forEach(function (el, idx) {
				$(el).data('rbOrig', idx);
			});
			items.sort(function (a, b) {
				var ta = titleFromItem(a);
				var tb = titleFromItem(b);
				var sa = scoreTitle(ta) + (siteTitles[ta] ? 40 : 0);
				var sb = scoreTitle(tb) + (siteTitles[tb] ? 40 : 0);
				if (sb !== sa) {
					return sb - sa;
				}
				return $(a).data('rbOrig') - $(b).data('rbOrig');
			});
			$list.append(items);
		}

		var siteTitles = {};
		if (!mw.Api) {
			resort(siteTitles);
			return;
		}
		new mw.Api()
			.get({
				action: 'query',
				list: 'categorymembers',
				cmtitle: 'Category:Ritchie Boys Wiki',
				cmlimit: 'max',
				cmnamespace: 0
			})
			.done(function (data) {
				var members = (data.query && data.query.categorymembers) || [];
				members.forEach(function (m) {
					if (m.title) {
						siteTitles[m.title] = true;
					}
				});
			})
			.always(function () {
				resort(siteTitles);
			});
	}());

	(function emptySearchHelp() {
		if (mw.config.get('wgCanonicalSpecialPageName') !== 'Search') {
			return;
		}
		var params = new URLSearchParams(window.location.search);
		var raw = String(params.get('search') || '').trim();
		if (!raw) {
			return;
		}
		var $results = $('.mw-search-results').first();
		var noneFound = $('.mw-search-nonefound').length > 0
			|| ($('.searchresults').length && !$results.length)
			|| ($results.length && $results.children('li').length === 0);
		if (!noneFound) {
			return;
		}
		if ($('.rb-search-empty-help').length) {
			return;
		}
		var contactUrl = mw.util.getUrl('Contact Us');
		var startUrl = mw.util.getUrl('Start here') + '#If_the_name_is_not_found';
		var $box = $('<div class="rb-search-empty-help" role="note"></div>');
		$box.append($('<p></p>').append(
			$('<strong></strong>').text('No soldier page matched “' + raw + '”.')
		));
		$box.append($('<ul></ul>').append(
			$('<li></li>').text('Try alternate spellings, anglicized names, or maiden vs married surnames.'),
			$('<li></li>').text('Try the ASN with or without leading zeros.'),
			$('<li></li>').text('They may be missing from the Classes 1–31 roster import or not yet matched.')
		));
		$box.append($('<p></p>').append(
			$('<a></a>').attr('href', startUrl).text('If the name is not found'),
			document.createTextNode(' · '),
			$('<a></a>').attr('href', contactUrl).text('Contact Us')
		));
		var $host = $('.mw-search-nonefound').first();
		if ($host.length) {
			$host.after($box);
		} else if ($('.searchresults').length) {
			$('.searchresults').first().prepend($box);
		} else {
			$('#mw-content-text').prepend($box);
		}
	}());



	(function buildGoogleFormEmbeds() {
		$('.mw-parser-output .google-form-embed[data-form-id]').each(function () {
			var $slot = $(this);
			if ($slot.data('rbGoogleFormEmbed')) {
				return;
			}
			$slot.data('rbGoogleFormEmbed', 1);
			var id = String($slot.attr('data-form-id') || '');
			if (!/^1FAIpQLS[A-Za-z0-9_-]+$/.test(id)) {
				return;
			}
			var src = 'https://docs.google.com/forms/d/e/' +
				encodeURIComponent(id) + '/viewform?embedded=true';
			var $iframe = $('<iframe class="google-form-frame" title="Ritchie Boys Contact Us Form" loading="lazy" allowfullscreen></iframe>')
				.attr({
					src: src,
					frameborder: '0',
					sandbox: 'allow-scripts allow-popups allow-forms allow-same-origin allow-popups-to-escape-sandbox allow-downloads allow-modals'
				});
			var $fallback = $slot.find('.google-form-fallback');
			if ($fallback.length) {
				$fallback.before($iframe);
			} else {
				$slot.prepend($iframe);
			}
		});
	}());

	(function buildDriveFolderEmbeds() {
		$('.mw-parser-output .drive-folder-embed[data-folder-id]').each(function () {
			var $slot = $(this);
			if ($slot.data('rbDriveEmbed')) {
				return;
			}
			$slot.data('rbDriveEmbed', 1);
			var id = String($slot.attr('data-folder-id') || '');
			if (!/^[A-Za-z0-9_-]+$/.test(id)) {
				return;
			}
			var view = String($slot.attr('data-view') || 'grid').replace(/[^a-z]/g, '') || 'grid';
			var src = 'https://drive.google.com/embeddedfolderview?id=' +
				encodeURIComponent(id) + '#' + view;
			var $iframe = $('<iframe class="drive-folder-frame" title="Google Drive photo folder" loading="lazy" allowfullscreen></iframe>')
				.attr('src', src);
			var $fallback = $slot.find('.drive-folder-fallback');
			if ($fallback.length) {
				$fallback.before($iframe);
			} else {
				$slot.prepend($iframe);
			}
		});
	}());

	(function buildYouTubeWatchlist() {
		var YT_ID = /^[A-Za-z0-9_-]{11}$/;

		function splitSoldiers(raw) {
			return String(raw || '').split(';').map(function (s) {
				return $.trim(s);
			}).filter(Boolean);
		}

		function overlap(a, b) {
			var i, j;
			for (i = 0; i < a.length; i++) {
				for (j = 0; j < b.length; j++) {
					if (a[i] === b[j]) {
						return true;
					}
				}
			}
			return false;
		}

		function playEmbed($slot, id, title) {
			var src = 'https://www.youtube-nocookie.com/embed/' +
				encodeURIComponent(id) + '?autoplay=1&rel=0';
			var $iframe = $('<iframe class="yt-embed-frame" allowfullscreen allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"></iframe>')
				.attr({
					src: src,
					title: title || 'YouTube video',
					loading: 'lazy'
				});
			$slot.empty().append($iframe).addClass('yt-embed-playing');
		}

		$('.mw-parser-output .yt-embed[data-youtube-id]').each(function () {
			var $slot = $(this);
			if ($slot.data('rbYtEmbed')) {
				return;
			}
			var id = String($slot.attr('data-youtube-id') || '');
			if (!YT_ID.test(id)) {
				return;
			}
			$slot.data('rbYtEmbed', 1);
			var title = String($slot.attr('data-title') || 'YouTube video');
			var $fallback = $slot.find('.yt-embed-fallback').first();
			var $btn = $('<button type="button" class="yt-embed-play"></button>')
				.attr('aria-label', 'Play ' + title)
				.append($('<span class="yt-embed-play-icon" aria-hidden="true"></span>'))
				.append($('<span class="yt-embed-play-text"></span>').text('Play video'));
			var $thumb = $('<img class="yt-embed-thumb" alt="">')
				.attr({
					src: 'https://i.ytimg.com/vi/' + encodeURIComponent(id) + '/hqdefault.jpg',
					alt: title
				});
			var $poster = $('<div class="yt-embed-poster"></div>').append($thumb).append($btn);
			if ($fallback.length) {
				$fallback.before($poster);
				$fallback.hide();
			} else {
				$slot.prepend($poster);
			}
			$poster.on('click', function (ev) {
				ev.preventDefault();
				playEmbed($slot, id, title);
			});
		});

		function interviewCardRef($el) {
			var yt = String($el.attr('data-youtube-id') || '');
			if (yt) {
				return { key: 'yt:' + yt, href: '#yt-' + yt };
			}
			var sc = String($el.attr('data-soundcloud-track') || '');
			if (sc) {
				return { key: 'sc:' + sc, href: '#sc-' + sc };
			}
			return null;
		}

		var $cards = $('.mw-parser-output .yt-interview').filter(function () {
			return interviewCardRef($(this)) !== null;
		});
		$cards.each(function (idx) {
			var $card = $(this);
			if ($card.data('rbYtNext')) {
				return;
			}
			$card.data('rbYtNext', 1);
			var $slot = $card.find('.yt-interview-next').first();
			if (!$slot.length) {
				return;
			}
			var mine = splitSoldiers($card.attr('data-soldiers'));
			var selfRef = interviewCardRef($card);
			var picks = [];
			var seen = {};

			function addCard($other, why) {
				var ref = interviewCardRef($other);
				if (!ref || !selfRef || seen[ref.key] || ref.key === selfRef.key) {
					return;
				}
				seen[ref.key] = 1;
				picks.push({
					href: ref.href,
					heading: String($other.attr('data-heading') || $other.find('h3, h2').first().text() || 'Interview'),
					short: String($other.attr('data-short') || ''),
					why: why
				});
			}

			$cards.each(function (j) {
				if (j === idx || picks.length >= 3) {
					return;
				}
				var $other = $($cards.get(j));
				if (overlap(mine, splitSoldiers($other.attr('data-soldiers')))) {
					addCard($other, 'same soldier');
				}
			});
			if (idx + 1 < $cards.length) {
				addCard($($cards.get(idx + 1)), 'next');
			}
			if (!picks.length) {
				return;
			}
			var $list = $('<ul class="yt-interview-next-list"></ul>');
			picks.slice(0, 3).forEach(function (item) {
				var label = item.short ? (item.heading + ' — ' + item.short) : item.heading;
				var $a = $('<a></a>').attr('href', item.href).text(label);
				var $li = $('<li></li>').append($a);
				if (item.why === 'same soldier') {
					$li.append($('<span class="yt-interview-next-why"></span>').text(' (same person)'));
				}
				$list.append($li);
			});
			$slot.empty()
				.append($('<div class="yt-interview-next-label"></div>').text('Watch next'))
				.append($list);
		});
	}());

	(function buildSoundCloudEmbeds() {
		var SC_TRACK = /^[0-9]{6,12}$/;

		function playSoundCloud($slot, trackId, title) {
			var src = 'https://w.soundcloud.com/player/?url=' +
				encodeURIComponent('https://api.soundcloud.com/tracks/' + trackId) +
				'&color=%23ff5500&auto_play=true&hide_related=true&show_comments=false' +
				'&show_user=true&show_reposts=false&show_teaser=false&visual=true';
			var $iframe = $('<iframe class="sc-embed-frame" scrolling="no" frameborder="no" allow="autoplay"></iframe>')
				.attr({
					src: src,
					title: title || 'SoundCloud audio',
					loading: 'lazy'
				});
			$slot.empty().append($iframe).addClass('sc-embed-playing');
		}

		$('.mw-parser-output .sc-embed[data-soundcloud-track]').each(function () {
			var $slot = $(this);
			if ($slot.data('rbScEmbed')) {
				return;
			}
			var trackId = String($slot.attr('data-soundcloud-track') || '');
			if (!SC_TRACK.test(trackId)) {
				return;
			}
			$slot.data('rbScEmbed', 1);
			var title = String($slot.attr('data-title') || 'SoundCloud audio');
			var $fallback = $slot.find('.sc-embed-fallback').first();
			var $btn = $('<button type="button" class="sc-embed-play"></button>')
				.attr('aria-label', 'Play ' + title)
				.append($('<span class="sc-embed-play-icon" aria-hidden="true"></span>'))
				.append($('<span class="sc-embed-play-text"></span>').text('Play audio'));
			var $poster = $('<div class="sc-embed-poster"></div>').append($btn);
			if ($fallback.length) {
				$fallback.before($poster);
				$fallback.hide();
			} else {
				$slot.prepend($poster);
			}
			$poster.on('click', function (ev) {
				ev.preventDefault();
				playSoundCloud($slot, trackId, title);
			});
		});
	}());

	(function buildAviaryEmbeds() {
		var AV_MEDIA = /^[0-9]{4,12}$/;

		function playAviary($slot, mediaId, title) {
			var src = 'https://milken.aviaryplatform.com/embed/media/' +
				encodeURIComponent(mediaId) +
				'?embed=true&media_player=true';
			var $iframe = $('<iframe class="aviary-embed-frame" allowfullscreen allow="autoplay; encrypted-media; fullscreen; picture-in-picture"></iframe>')
				.attr({
					src: src,
					title: title || 'Aviary media',
					loading: 'lazy'
				});
			$slot.empty().append($iframe).addClass('aviary-embed-playing');
		}

		$('.mw-parser-output .aviary-embed[data-aviary-media]').each(function () {
			var $slot = $(this);
			if ($slot.data('rbAviaryEmbed')) {
				return;
			}
			var mediaId = String($slot.attr('data-aviary-media') || '');
			if (!AV_MEDIA.test(mediaId)) {
				return;
			}
			$slot.data('rbAviaryEmbed', 1);
			var title = String($slot.attr('data-title') || 'Aviary media');
			var playLabel = String($slot.attr('data-play-label') || 'Play video');
			var $fallback = $slot.find('.aviary-embed-fallback').first();
			var $btn = $('<button type="button" class="aviary-embed-play"></button>')
				.attr('aria-label', playLabel + ' — ' + title)
				.append($('<span class="aviary-embed-play-icon" aria-hidden="true"></span>'))
				.append($('<span class="aviary-embed-play-text"></span>').text(playLabel));
			var $poster = $('<div class="aviary-embed-poster"></div>').append($btn);
			if ($fallback.length) {
				$fallback.before($poster);
				$fallback.hide();
			} else {
				$slot.prepend($poster);
			}
			$poster.on('click', function (ev) {
				ev.preventDefault();
				playAviary($slot, mediaId, title);
			});
		});
	}());

	(function buildInternetArchiveEmbeds() {
		// archive.org item identifiers (e.g. RobertA.Potash13january2012YiddishBookCenter)
		var IA_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{5,120}$/;

		function playInternetArchive($slot, itemId, title) {
			var src = 'https://archive.org/embed/' + encodeURIComponent(itemId);
			var $iframe = $('<iframe class="ia-embed-frame" allowfullscreen allow="autoplay; encrypted-media; fullscreen; picture-in-picture"></iframe>')
				.attr({
					src: src,
					title: title || 'Internet Archive media',
					loading: 'lazy'
				});
			$slot.empty().append($iframe).addClass('ia-embed-playing');
		}

		$('.mw-parser-output .ia-embed[data-archive-id]').each(function () {
			var $slot = $(this);
			if ($slot.data('rbIaEmbed')) {
				return;
			}
			var itemId = String($slot.attr('data-archive-id') || '');
			if (!IA_ID.test(itemId)) {
				return;
			}
			$slot.data('rbIaEmbed', 1);
			var title = String($slot.attr('data-title') || 'Internet Archive media');
			var playLabel = String($slot.attr('data-play-label') || 'Play interview');
			var $fallback = $slot.find('.ia-embed-fallback').first();
			var $btn = $('<button type="button" class="ia-embed-play"></button>')
				.attr('aria-label', playLabel + ' — ' + title)
				.append($('<span class="ia-embed-play-icon" aria-hidden="true"></span>'))
				.append($('<span class="ia-embed-play-text"></span>').text(playLabel));
			var $poster = $('<div class="ia-embed-poster"></div>').append($btn);
			if ($fallback.length) {
				$fallback.before($poster);
				$fallback.hide();
			} else {
				$slot.prepend($poster);
			}
			$poster.on('click', function (ev) {
				ev.preventDefault();
				playInternetArchive($slot, itemId, title);
			});
		});
	}());

	(function placeSoldierAwardsSection() {
		// Ribbon racks belong in the main column after Biography (like Wikipedia's
		// Awards section). Do not use a wiki == heading here — that stole the TOC.
		var $out = $('.mw-parser-output').first();
		var $awards = $out.find('.soldier-awards-section').first();
		if (!$out.length) {
			return;
		}

		function sectionAfter($h) {
			var $nodes = $h.nextUntil('h2');
			if ($nodes.length) {
				return $nodes.last();
			}
			return $h;
		}

		if ($awards.length && !$awards.data('rbAwardsPlaced')) {
			$awards.data('rbAwardsPlaced', 1);
			var $bio = $out.find('h2').filter(function () {
				var t = $(this).find('.mw-headline').text() || $(this).text();
				return /^(Biography|Life)\b/i.test($.trim(t));
			}).first();
			if ($bio.length) {
				sectionAfter($bio).after($awards);
			} else {
				var $before = $out.find('h2').filter(function () {
					var t = $(this).find('.mw-headline').text() || $(this).text();
					return /^(Gallery|Research notes|Sources|Technical record)\b/i.test($.trim(t));
				}).first();
				if ($before.length) {
					$before.before($awards);
				} else {
					$out.append($awards);
				}
			}
		}

		// Class/team rail rides with awards (after Biography).
		var $related = $out.find('.soldier-related').first();
		if ($related.length && !$related.data('rbRelatedPlaced')) {
			$related.data('rbRelatedPlaced', 1);
			var $awardsNow = $out.find('.soldier-awards-section').first();
			if ($awardsNow.length) {
				$awardsNow.after($related);
			} else {
				var $bio2 = $out.find('h2').filter(function () {
					var t = $(this).find('.mw-headline').text() || $(this).text();
					return /^(Biography|Life)\b/i.test($.trim(t));
				}).first();
				if ($bio2.length) {
					sectionAfter($bio2).after($related);
				}
			}
		}

		// Story cue belongs under the lead (above Biography).
		var $storyCue = $out.find('.soldier-story-cue').first();
		if ($storyCue.length && !$storyCue.data('rbStoryCuePlaced')) {
			$storyCue.data('rbStoryCuePlaced', 1);
			var $leadCue = $out.find('.soldier-lead, .page-lead').first();
			if ($leadCue.length) {
				$leadCue.after($storyCue);
			}
		}

		// Interview / film cue sits with the story cue under the lead.
		var $mediaCue = $out.find('.soldier-media-cue').first();
		if ($mediaCue.length && !$mediaCue.data('rbMediaCuePlaced')) {
			$mediaCue.data('rbMediaCuePlaced', 1);
			var $afterStory = $out.find('.soldier-story-cue').first();
			if ($afterStory.length) {
				$afterStory.after($mediaCue);
			} else {
				var $leadMedia = $out.find('.soldier-lead, .page-lead').first();
				if ($leadMedia.length) {
					$leadMedia.after($mediaCue);
				}
			}
		}

		// Contents box belongs after the lead on soldier/profile pages only.
		// Other pages (e.g. Site changelog) keep MediaWiki's default TOC placement.
		var $toc = $out.find('#toc, .toc, .mw-table-of-contents').first();
		if ($toc.length && !$toc.data('rbTocPlaced')) {
			$toc.data('rbTocPlaced', 1);
			var $lead = $out.find('.soldier-lead, .page-lead, .home-lead').first();
			if ($lead.length) {
				$lead.after($toc);
			} else {
				var $ibox = $out.find('table.soldier-infobox, table.infobox').first();
				if ($ibox.length) {
					$ibox.after($toc);
				}
			}
		}
	}());

	(function moveSoldierFooterCues() {
		var $out = $('.mw-parser-output').first();
		if (!$out.length) {
			return;
		}
		var $notes = $out.find('.research-notes-disclaimer').first();
		var $sources = $out.find('.source-confidence-cue').first();
		if (!$notes.length && !$sources.length) {
			return;
		}
		var $footer = $out.find('.soldier-page-footer-notes').first();
		if (!$footer.length) {
			$footer = $('<div class="soldier-page-footer-notes"></div>');
			$out.append($footer);
		}
		if ($notes.length) {
			$footer.append($notes);
		}
		if ($sources.length) {
			$footer.append($sources);
		}
	}());

	var NAME_PARTICLES = {
		von: 1, van: 1, de: 1, der: 1, den: 1, da: 1, di: 1, du: 1,
		la: 1, le: 1, del: 1, della: 1, ter: 1, ten: 1, zu: 1, zum: 1, bin: 1
	};
	var NAME_SUFFIX = {
		jr: 'Jr', 'jr.': 'Jr.', sr: 'Sr', 'sr.': 'Sr.',
		esq: 'Esq', 'esq.': 'Esq.', ii: 'II', iii: 'III', iv: 'IV'
	};
	function lettersOnly(s) {
		return String(s || '').replace(/[^A-Za-z]/g, '');
	}
	function isAllCaps(s) {
		var letters = lettersOnly(s);
		return letters.length >= 1 && letters === letters.toUpperCase();
	}
	function isAllLower(s) {
		var letters = lettersOnly(s);
		return letters.length >= 2 && letters === letters.toLowerCase();
	}
	function titleCaseWord(s) {
		s = String(s || '').toLowerCase();
		return s ? s.charAt(0).toUpperCase() + s.slice(1) : s;
	}
	function prettyCapsWord(word) {
		var trail = '';
		while (word && /[.,]$/.test(word)) {
			trail = word.slice(-1) + trail;
			word = word.slice(0, -1);
		}
		var up = lettersOnly(word).toUpperCase();
		if (up.length >= 5 && up.slice(0, 2) === 'MC') {
			return 'Mc' + titleCaseWord(up.slice(2)) + trail;
		}
		if (up.length >= 7 && up.slice(0, 3) === 'MAC') {
			return 'Mac' + titleCaseWord(up.slice(3)) + trail;
		}
		return titleCaseWord(up) + trail;
	}
	function prettyNameToken(word, leading) {
		word = $.trim(word);
		if (!word) {
			return word;
		}
		if (/\d/.test(word)) {
			return word;
		}
		if (word.indexOf('-') !== -1 && word.charAt(0) !== '-' && word.slice(-1) !== '-') {
			return $.map(word.split('-'), function (part, i) {
				return prettyNameToken(part, leading && i === 0);
			}).join('-');
		}
		var apos = word.indexOf("'") !== -1 ? "'" : (word.indexOf('\u2019') !== -1 ? '\u2019' : '');
		if (apos) {
			var bits = word.split(apos);
			return prettyNameToken(bits[0], leading) + apos + prettyNameToken(bits.slice(1).join(apos), false);
		}
		if (/^(?:[A-Za-z]\.)+$/.test(word)) {
			return (isAllCaps(word) || isAllLower(word)) ? word.toUpperCase() : word;
		}
		var low = word.toLowerCase();
		if (NAME_SUFFIX[low]) {
			return NAME_SUFFIX[low];
		}
		var bare = lettersOnly(word);
		if (bare.length === 1) {
			return bare.toUpperCase() + word.replace(/[A-Za-z]/, '');
		}
		var lowBare = bare.toLowerCase();
		if (NAME_SUFFIX[lowBare] && /^[A-Za-z]+\.?$/.test(word)) {
			var canon = NAME_SUFFIX[lowBare];
			if (word.slice(-1) === '.' && canon.slice(-1) !== '.') {
				return canon + '.';
			}
			return canon;
		}
		if (NAME_PARTICLES[lowBare] && /^[A-Za-z]+$/.test(word) && (isAllCaps(word) || isAllLower(word))) {
			return leading ? titleCaseWord(lowBare) : lowBare;
		}
		if (isAllCaps(word) && bare.length >= 2 && bare.length <= 3 && !/[AEIOUY]/i.test(bare)) {
			return word;
		}
		if (isAllCaps(word) || isAllLower(word)) {
			return prettyCapsWord(word);
		}
		return word;
	}
	function prettyNamePhrase(text, capLead) {
		text = $.trim(text || '').replace(/\s*;\s*/g, ' ');
		if (!text) {
			return '';
		}
		return $.map(text.split(/\s*,\s*/), function (clause, clauseI) {
			clause = $.trim(clause);
			if (!clause) {
				return null;
			}
			var words = clause.split(/\s+/);
			return $.map(words, function (word, i) {
				var particle = lettersOnly(word).toLowerCase();
				var leading = (clauseI === 0 && i === 0 && capLead) || words.length === 1;
				if (NAME_PARTICLES[particle] && /^[A-Za-z]+$/.test(word) && (isAllCaps(word) || isAllLower(word))) {
					return leading ? titleCaseWord(particle) : word.toLowerCase();
				}
				return prettyNameToken(word, i === 0);
			}).join(' ');
		}).join(', ');
	}
	function prettySoldierLabel(text) {
		var m = $.trim(text || '').match(/^(.*) \(([^()]+)\)\s*$/);
		if (!m || m[1].indexOf('(') !== -1) {
			return prettyNamePhrase(text, true);
		}
		return prettyNamePhrase(m[1], true) + ' (' + m[2] + ')';
	}
	function maybePrettySoldierTitle(label) {
		var text = $.trim(label || '');
		if (!text || text.indexOf(',') === -1) {
			return text;
		}
		var sur = text.split(',')[0];
		var letters = lettersOnly(sur);
		if (!(letters.length >= 2 && letters === letters.toUpperCase())) {
			return text;
		}
		return prettySoldierLabel(text);
	}
	function applyPrettySoldierLabels($links) {
		$links.each(function () {
			var $a = $(this);
			var label = $a.text();
			var pretty = maybePrettySoldierTitle(label);
			if (pretty && pretty !== label) {
				$a.text(pretty);
				if ($a.attr('title') === label || !$a.attr('title')) {
					$a.attr('title', pretty);
				}
			}
		});
	}
	if (mw.config.get('wgNamespaceNumber') === 14) {
		applyPrettySoldierLabels($('.mw-category-group li a'));
	}
	if (mw.config.get('wgCanonicalSpecialPageName') === 'Search') {
		applyPrettySoldierLabels($('.mw-search-result-heading a'));
	}
	// Search box suggestions use the real page title; pretty-case ALL-CAPS surnames as they appear.
	(function watchSearchSuggestions() {
		function polish() {
			$('.suggestions-result, .suggestions-result a, .suggestions-special a, .cdx-menu-item, .mw-searchSuggest-link').each(function () {
				var $el = $(this);
				var label = $.trim($el.text() || '');
				if (rbIsMergedDuplicateTitle(label)) {
					$el.closest('li, .cdx-menu-item, .suggestions-result').addBack().filter('li, .cdx-menu-item, .suggestions-result').first().hide();
				}
			});
			applyPrettySoldierLabels(
				$('.suggestions-result a, .suggestions-special a, .cdx-menu-item__text__label, .mw-searchSuggest-link')
			);
			$('.suggestions-result, .cdx-menu-item__text__label').each(function () {
				var $el = $(this);
				if ($el.is('a')) {
					return;
				}
				var label = $el.text();
				var pretty = maybePrettySoldierTitle(label);
				if (pretty && pretty !== label) {
					$el.text(pretty);
				}
			});
		}
		var root = document.body;
		if (!root || !window.MutationObserver) {
			return;
		}
		var obs = new MutationObserver(function () {
			polish();
		});
		obs.observe(root, { childList: true, subtree: true });
	}());

	$('.infobox th, .soldier-infobox th, table.wikitable td').each(function () {
		var text = $(this).text().replace(/\s+/g, ' ').trim();
		if (
			text === 'Overseas' ||
			text === 'Over Seas Flag' ||
			text === 'Special'
		) {
			$(this).closest('tr').remove();
		}
	});

	function teamHref(name) {
		return mw.util.getUrl('Team ' + name);
	}
	function teamLink(name, label) {
		return '<a href="' + teamHref(name) + '" title="Team ' + name + '">' + label + '</a>';
	}
	function linkifyTeams(raw) {
		var text = $('<div>').text(raw).html();
		text = text.replace(/\bMII[\s-–]+(540|545|546)\s*[-–]\s*R\b/gi, function (_, num) {
			return teamLink('MII ' + num, 'MII ' + num);
		});
		text = text.replace(/(?<!\bMII[\s-–])\b(540|545|546)\s*[-–]\s*R\b/g, function (_, num) {
			return teamLink('MII ' + num, num);
		});
		text = text.replace(/\bMII[\s-–]+(\d{2,4})\s*[-–]\s*([A-Za-z])\b/gi, function (_, num, letter) {
			var name = 'MII ' + num + '-' + letter.toUpperCase();
			return teamLink(name, 'MII ' + num + '-' + letter.toUpperCase());
		});
		text = text.replace(/\bMII[\s-–]+(\d{2,4})\b/gi, function (_, num) {
			return teamLink('MII ' + num, 'MII ' + num);
		});
		text = text.replace(/(?<!\bMII[\s-–])\b([4-5]\d{2})\s*[-–]\s*([A-Za-z])\b/g, function (_, num, letter) {
			var name = 'MII ' + num + '-' + letter.toUpperCase();
			return teamLink(name, num + '-' + letter.toUpperCase());
		});
		text = text.replace(/\b(?:IPW\s+)?(?:Italian\s+)?5th\s+class\b/gi, function () {
			return teamLink('IPW Italian 5', 'IPW Italian 5');
		});
		text = text.replace(/\bIPW\s+(\d{1,4})\s*[-–]\s*([A-Za-z])\b/gi, function (_, num, letter) {
			var name = 'MII ' + num + '-' + letter.toUpperCase();
			return teamLink(name, 'MII ' + num + '-' + letter.toUpperCase());
		});
		text = text.replace(/\bIPW\s+(\d{1,4})[Tt]?\s*\?/gi, function (_, num) {
			return teamLink('IPW ' + num, 'IPW ' + num);
		});
		text = text.replace(/\bIPW\s+(\d{1,4})[Tt]?\b/gi, function (_, num) {
			return teamLink('IPW ' + num, 'IPW ' + num);
		});
		text = text.replace(/\b(\d{1,4})[Tt]\b/g, function (_, num) {
			return teamLink('IPW ' + num, num + 'T');
		});
		text = text.replace(/\b(\d{1,4})\s*\?/g, function (_, num) {
			return teamLink('IPW ' + num, num);
		});
		return text;
	}

	function normalizeNoteKey(text) {
		text = $.trim(text || '').toLowerCase();
		text = text.replace(/^also\s+/, '');
		text = text.replace(/[^\w\s\-/]/g, ' ');
		return text.replace(/\s+/g, ' ').trim();
	}

	function dateOnlyFragment(text) {
		var m = $.trim(text || '').match(/^(\d{1,2}\/\d{1,2}\/\d{2,4})\.?$/);
		return m ? m[1] : '';
	}

	function joinDateFollowons(parts) {
		var out = [];
		$.each(parts, function (_, clause) {
			clause = $.trim(clause || '');
			if (!clause) {
				return;
			}
			var date = dateOnlyFragment(clause);
			if (date && out.length) {
				var prev = out[out.length - 1].replace(/[ ,;]+$/, '');
				if (prev.slice(-date.length) !== date) {
					out[out.length - 1] = prev + ', ' + date;
				}
				return;
			}
			out.push(clause);
		});
		return out;
	}

	function splitNotes(text) {
		text = $.trim(text || '');
		if (!text) {
			return [];
		}
		text = text.replace(/"+/g, '"');
		var parts = [];
		$.each(text.split(/[;"]+/), function (_, chunk) {
			chunk = $.trim(chunk.replace(/^['"]+|['"]+$/g, ''));
			if (!chunk) {
				return;
			}
			if (/https?:\/\//i.test(chunk)) {
				parts.push(chunk);
				return;
			}
			chunk = chunk.replace(/(\d{1,2}\/\d{1,2}\/\d{2,4})(?=[A-Za-z])/g, '$1\n');
			$.each(chunk.split('\n'), function (__, bit) {
				bit = $.trim(bit.replace(/^['"]+|['"]+$/g, ''));
				if (!bit) {
					return;
				}
				var dated = bit.match(/^(.*?)\s+(\d{1,2}\/\d{1,2}\/\d{2,4})$/);
				if (dated && $.trim(dated[1]) && !/^\d{1,2}\/\d{1,2}\/\d{2,4}$/.test($.trim(dated[1]))) {
					parts.push($.trim(dated[1]));
					parts.push(dated[2]);
				} else {
					parts.push(bit);
				}
			});
		});
		return joinDateFollowons(parts);
	}

	var negative = /fail(?:ed|ure)?|flunk|wash(?:ed)?\s*out|kick(?:ed)?\s*out|dismiss|expel|unsatisfactor|poor\s+(?:performance|work|grades)|awol|court[-\s]?martial|disciplin|discharged\s+for\s+cause|drop(?:ped)?\s+(?:out|from)|relieved|eliminat|unfit|misconduct|desert|excess\s+absence|absence\s*\(|\billness\b|sick\s+call|medical\s+board|psychiatr|neurot|homosexual|suicid|alcohol(?:ism)?|drunk|intoxicat|venereal|\bVD\b|syphili|gonorrh|mental\s+(?:case|illness)|nervous\s+breakdown|not\s+graduat|did\s+not\s+graduate|physically\s+unfit|unfit\s+for|non[-\s]?graduate|certificate\s+listed\s+as\s+ng|(?:^|\b)NG\b(?:\s+Class|\s+OB|\s*$|\s*[,;:/]|\s+\d)|\bnongrad/i;
	var $ul = $heading.nextAll('ul').first();
	if (!$ul.length) {
		return;
	}
	var items = [];
	$ul.find('li').each(function () {
		$.each(splitNotes($(this).text()), function (_, clause) {
			if (clause) {
				items.push(clause);
			}
		});
	});
	items = joinDateFollowons(items);
	var unique = [];
	var seen = {};
	$.each(items, function (_, clause) {
		var key = normalizeNoteKey(clause);
		if (clause && key && !negative.test(clause) && !seen[key]) {
			seen[key] = true;
			unique.push(clause);
		}
	});
	items = unique;
	$ul.empty();
	$.each(items, function (_, clause) {
		$ul.append($('<li>').html(linkifyTeams(clause)));
	});
});

/**
 * Soldier page contribute tools — compact disclosure under Gallery heading.
 * Sits in the text column beside the floated infobox (no clear:both gap).
 */
$(function () {
	if (mw.config.get('wgNamespaceNumber') !== 0 || !$('.soldier-infobox').length) {
		return;
	}

	var PHOTO_EXT = /\.(jpe?g|png|gif|webp)$/i;
	var DOC_EXT = /\.(pdf|docx?|txt|rtf|odt|tiff?)$/i;
	var ALLOWED = /\.(jpe?g|png|gif|webp|pdf|docx?|txt|rtf|odt|tiff?)$/i;

	function headingText($el) {
		var $h = $el.is('h2') ? $el : $el.find('h2').first();
		return $.trim(($h.text() || '').replace(/\s*\[edit.*$/i, '').replace(/\s*edit\s*(source)?\s*/ig, ''));
	}

	function headingWrap($h2) {
		var $wrap = $h2.closest('.mw-heading');
		return $wrap.length ? $wrap : $h2;
	}

	function findSectionHeading(names) {
		var re = new RegExp('^(' + names.join('|') + ')$', 'i');
		var $found = $();
		$('h2').each(function () {
			if (re.test(headingText($(this)))) {
				$found = $(this);
				return false;
			}
		});
		return $found;
	}

	function ensureGalleryAnchor() {
		var $h2 = $('#Gallery');
		if (!$h2.length || !$h2.is('h2')) {
			$h2 = findSectionHeading(['Gallery']);
		}
		if ($h2.length) {
			return headingWrap($h2);
		}
		var $anchor = findSectionHeading(['Technical record', 'Sources']);
		var $wrap = $('<div>', { 'class': 'mw-heading mw-heading2' });
		$wrap.append($('<h2>', { id: 'Gallery' }).text('Gallery'));
		if ($anchor.length) {
			headingWrap($anchor).before($wrap);
		} else {
			$('.mw-parser-output').first().append($wrap);
		}
		return $wrap;
	}

	function placeContributeBox($box) {
		if ($('.soldier-upload-box').length) {
			return;
		}
		/* Always directly under the Gallery heading — same place on every profile. */
		ensureGalleryAnchor().after($box);
	}

	var pageTitle = mw.config.get('wgPageName');
	var loggedIn = !!mw.config.get('wgUserName');
	var uploadUrl = mw.util.getUrl('Special:Upload') + '&wpRitchieSoldierPage=' + encodeURIComponent(pageTitle);

	var $box = $('<details>', { 'class': 'soldier-upload-box' });
	var $summary = $('<summary>').text('Add photos or OCR a document');
	$box.append($summary);

	var $body = $('<div>', { 'class': 'soldier-upload-body' });
	$body.append($('<p>', { 'class': 'soldier-contrib-help' }).append(
		document.createTextNode('Photos for the portrait or Gallery, or a document scan for OCR review. Also '),
		$('<a>', { href: uploadUrl, text: 'Special:Upload' }),
		document.createTextNode('.')
	));

	var $photoSection = $('<div>', { 'class': 'soldier-contrib-section soldier-contrib-upload' });
	$photoSection.append($('<p>', { 'class': 'soldier-contrib-heading' }).append(
		$('<strong>').text('Photo or document upload')
	));

	if (!loggedIn) {
		$photoSection.append($('<p>', { 'class': 'soldier-upload-login' }).html(
			'<a href="' + mw.util.getUrl('Special:UserLogin', { returnto: pageTitle }) + '">Log in</a> to upload files or run document OCR.'
		));
		$body.append($photoSection);
		$body.append($('<div>', { 'class': 'soldier-ocr-slot' }));
		$box.append($body);
		placeContributeBox($box);
		return;
	}

	var $form = $('<form>', { 'class': 'soldier-upload-form' });
	var $file = $('<input>', { type: 'file', name: 'ritchieUploadFile', 'class': 'soldier-upload-file' });
	var $profile = $('<label>').append(
		$('<input>', { type: 'checkbox', name: 'ritchieProfile', checked: true }),
		' Use as profile photo (images only)'
	);
	var $gallery = $('<label>').append(
		$('<input>', { type: 'checkbox', name: 'ritchieGallery' }),
		' Add to Gallery (documents, or extra photos)'
	);
	var $status = $('<p>', { 'class': 'soldier-upload-status', text: '' });
	var $submit = $('<button>', { type: 'submit', text: 'Upload file' });

	$form.append($file, $profile, $gallery, $submit, $status);
	$photoSection.append($form);
	$body.append($photoSection);
	$body.append($('<div>', { 'class': 'soldier-ocr-slot' }));
	$box.append($body);
	placeContributeBox($box);

	function setStatus(msg, isError) {
		$status.text(msg).css('color', isError ? '#ba0000' : '#202122');
	}

	function refreshChecks() {
		var name = ($file.val() || '').split(/[/\\]/).pop();
		var isPhoto = PHOTO_EXT.test(name);
		var isDoc = DOC_EXT.test(name);
		$profile.toggle(isPhoto || !name);
		if (isDoc) {
			$profile.prop('checked', false);
			$gallery.prop('checked', true);
		} else if (isPhoto) {
			$profile.prop('checked', true);
			$gallery.prop('checked', false);
		}
	}

	$file.on('change', refreshChecks);

	$form.on('submit', function (evt) {
		evt.preventDefault();
		var file = $file[0].files[0];
		if (!file) {
			setStatus('Choose a file first.', true);
			return;
		}
		if (!ALLOWED.test(file.name)) {
			setStatus('File type not allowed.', true);
			return;
		}
		var isPhoto = PHOTO_EXT.test(file.name);
		var isDoc = DOC_EXT.test(file.name);
		var useProfile = $profile.prop('checked') && isPhoto;
		var addGallery = $gallery.prop('checked') || isDoc;
		if (!useProfile && !addGallery) {
			setStatus('Choose profile photo and/or Gallery.', true);
			return;
		}

		$submit.prop('disabled', true);
		setStatus('Uploading…');

		var api = new mw.Api();
		var fileText = (isDoc ? 'Document' : 'Portrait') + ' for [[' + pageTitle + ']].';
		api.postWithToken('csrf', {
			action: 'upload',
			filename: file.name,
			file: file,
			comment: 'Uploaded from soldier page',
			text: fileText,
			format: 'json',
			ignorewarnings: true
		}, {
			contentType: 'multipart/form-data'
		}).then(function (data) {
			var uploaded = data.upload && data.upload.filename;
			if (!uploaded) {
				throw new Error('Upload failed');
			}
			setStatus('Attaching to page…');
			return api.postWithToken('csrf', {
				action: 'ritchieattachfile',
				filename: uploaded,
				soldier: pageTitle,
				profile: useProfile ? 1 : 0,
				gallery: addGallery ? 1 : 0,
				format: 'json'
			});
		}).done(function () {
			setStatus('Done. Reloading…');
			window.location.reload();
		}).fail(function (code, data) {
			var err = (data && data.error && data.error.info) || code || 'Upload failed';
			setStatus(err, true);
			$submit.prop('disabled', false);
		});
	});
});

/**
 * Soldier page easy-edit form — structured infobox fields without wikitext.
 */
$(function () {
	if (mw.config.get('wgNamespaceNumber') !== 0 || !$('.soldier-infobox').length) {
		return;
	}

	var pageTitle = mw.config.get('wgPageName');
	var editUrl = mw.util.getUrl(pageTitle, { action: 'edit' });
	var sourceUrl = mw.util.getUrl(pageTitle, { action: 'edit', veaction: 'editsource' });

	var FACT_GROUPS = [
		{
			title: 'Personal details',
			fields: [
				{ key: 'alias', label: 'Birth name' },
				{ key: 'birth_year', label: 'Birth year', hint: 'Four digits, e.g. 1924' },
				{ key: 'death_year', label: 'Death year', hint: 'Four digits, e.g. 2001' },
				{ key: 'nativity', label: 'Nativity (birthplace)' },
				{ key: 'religion', label: 'Religion' }
			]
		},
		{
			title: 'Military service',
			fields: [
				{ key: 'class_number', label: 'Class number(s)', hint: 'Semicolon for multiple, e.g. 5;11' },
				{ key: 'class_section', label: 'Class section' },
				{ key: 'class_rank', label: 'Class rank' },
				{ key: 'class_branch', label: 'Branch' },
				{ key: 'enlistment_place', label: 'Place of enlistment' },
				{ key: 'ipw_ge', label: 'IPW / German team', hint: 'Roster team number, e.g. 69' },
				{ key: 'ipw_it', label: 'IPW / Italian team' },
				{ key: 'teams', label: 'Additional teams', hint: 'Semicolon-separated, e.g. MII 439-G; IPW 12' },
				{ key: 'rank_final', label: 'Final rank' },
				{ key: 'specialty', label: 'Specialty', hint: 'e.g. IPW Ge, French, German' }
			]
		},
		{
			title: 'Additional record fields',
			collapsed: true,
			fields: [
				{
					key: 'other_class',
					label: 'Other class',
					hint: 'Roster code, e.g. SC8, DC36, MITU4 (shown expanded on the page)'
				},
				{ key: 'division', label: 'Unit / division' },
				{ key: 'loc', label: 'LOC' }
			]
		}
	];

	var $box = $('<div>', { 'class': 'soldier-facts-box' });
	var $intro = $('<p>', { 'class': 'soldier-facts-intro' });
	$intro.append(
		$('<strong>', { 'class': 'soldier-facts-label' }).text('Editing this page'),
		$('<span>', { 'class': 'soldier-facts-help' }).text(' — '),
		$('<button>', { type: 'button', 'class': 'soldier-facts-toggle', text: 'Edit soldier facts' }),
		$('<span>', { 'class': 'soldier-facts-help' }).text(' (infobox fields), '),
		$('<a>', { href: editUrl, text: 'Edit biography' }),
		$('<span>', { 'class': 'soldier-facts-help' }).text(' (visual editor for Biography and notes), or '),
		$('<a>', { href: sourceUrl, text: 'Edit source' }),
		$('<span>', { 'class': 'soldier-facts-help' }).text(' (full page markup).')
	);
	$box.append($intro);

	var $draftBar = $('<p>', { 'class': 'soldier-summary-bar' });
	var $draftBtn = $('<button>', { type: 'button', 'class': 'soldier-summary-toggle', text: 'Draft opening summary' });
	$draftBar.append(
		$draftBtn,
		$('<span>', { 'class': 'soldier-summary-help' }).text(
			' — optional 5–10 sentence opening from Personal details and the Biography section. Review before saving.'
		)
	);
	$box.append($draftBar);

	var $draftPanel = $('<div>', { 'class': 'soldier-summary-panel', hidden: true });
	var $draftHint = $('<p>', { 'class': 'soldier-summary-hint', text: '' });
	var $draftArea = $('<textarea>', { 'class': 'soldier-summary-text', rows: 8 });
	var $draftStatus = $('<p>', { 'class': 'soldier-summary-status', text: '' });
	var $draftUse = $('<button>', { type: 'button', text: 'Use this summary' });
	var $draftClose = $('<button>', { type: 'button', 'class': 'soldier-facts-cancel', text: 'Close' });
	$draftPanel.append(
		$draftHint,
		$draftArea,
		$('<div>', { 'class': 'soldier-facts-actions' }).append($draftUse, ' ', $draftClose),
		$draftStatus
	);
	$box.append($draftPanel);

	var $panel = $('<div>', { 'class': 'soldier-facts-panel', hidden: true });
	var $identity = $('<p>', { 'class': 'soldier-facts-identity', text: '' });
	var $form = $('<form>', { 'class': 'soldier-facts-form' });
	var $status = $('<p>', { 'class': 'soldier-facts-status', text: '' });
	var $actions = $('<div>', { 'class': 'soldier-facts-actions' });
	var $save = $('<button>', { type: 'submit', text: 'Save facts' });
	var $cancel = $('<button>', { type: 'button', text: 'Cancel', 'class': 'soldier-facts-cancel' });
	$actions.append($save, ' ', $cancel);
	$form.append($identity);

	$.each(FACT_GROUPS, function (_, group) {
		var $fieldset = $('<fieldset>', { 'class': 'soldier-facts-group' });
		if (group.collapsed) {
			$fieldset.addClass('soldier-facts-group-collapsed');
		}
		$fieldset.append($('<legend>').text(group.title));
		$.each(group.fields, function (__, field) {
			var id = 'ritchie-fact-' + field.key;
			var $label = $('<label>', { 'for': id, text: field.label });
			var $input = $('<input>', {
				type: 'text',
				id: id,
				name: field.key,
				'class': 'soldier-facts-input',
				'data-key': field.key
			});
			if (field.hint) {
				$input.attr('title', field.hint);
				$input.attr('placeholder', field.hint);
			}
			$fieldset.append($('<div>', { 'class': 'soldier-facts-field' }).append($label, $input));
		});
		$form.append($fieldset);
	});

	$form.append($actions, $status);
	$panel.append($form);
	$box.append($panel);

	var $heading = $('#firstHeading').first();
	if ($heading.length) {
		$heading.before($box);
	} else {
		var $output = $('.mw-parser-output').first();
		$output.prepend($box);
	}

	var loaded = false;
	var loading = false;

	function setStatus(msg, isError) {
		$status.text(msg).css('color', isError ? '#ba0000' : '#202122');
	}

	function loadFacts() {
		if (loaded || loading) {
			return $.Deferred().resolve().promise();
		}
		loading = true;
		setStatus('Loading…');
		return new mw.Api().get({
			action: 'ritchiegetsoldierfacts',
			title: pageTitle,
			format: 'json'
		}).done(function (data) {
			var facts = data.ritchiegetsoldierfacts || {};
			var nameParts = $.grep([facts.first, facts.surname], Boolean);
			$identity.text(
				(nameParts.join(' ') || pageTitle) +
				(facts.asn ? ' (ASN ' + facts.asn + ')' : '')
			);
			$form.find('.soldier-facts-input').each(function () {
				var key = $(this).data('key');
				$(this).val(facts[key] || '');
			});
			loaded = true;
			setStatus('');
		}).fail(function (code, data) {
			var err = (data && data.error && data.error.info) || code || 'Could not load facts';
			setStatus(err, true);
		}).always(function () {
			loading = false;
		});
	}

	$box.find('.soldier-facts-toggle').on('click', function () {
		var open = !$panel.prop('hidden');
		if (open) {
			$panel.prop('hidden', true);
			$(this).text('Edit soldier facts');
			setStatus('');
			return;
		}
		$panel.prop('hidden', false);
		$(this).text('Hide soldier facts form');
		loadFacts();
	});

	$cancel.on('click', function () {
		$panel.prop('hidden', true);
		$box.find('.soldier-facts-toggle').text('Edit soldier facts');
		setStatus('');
	});

	$form.on('submit', function (evt) {
		evt.preventDefault();
		var payload = {
			action: 'ritchieeditsoldierfacts',
			title: pageTitle,
			format: 'json'
		};
		$form.find('.soldier-facts-input').each(function () {
			payload[$(this).data('key')] = $(this).val();
		});

		$save.prop('disabled', true);
		setStatus('Saving…');

		new mw.Api().postWithToken('csrf', payload).done(function () {
			setStatus('Saved. Reloading…');
			window.location.reload();
		}).fail(function (code, data) {
			var err = (data && data.error && data.error.info) || code || 'Save failed';
			setStatus(err, true);
			$save.prop('disabled', false);
		});
	});

	function setDraftStatus(msg, isError) {
		$draftStatus.text(msg).css('color', isError ? '#ba0000' : '#202122');
	}

	$draftBtn.on('click', function () {
		$draftPanel.prop('hidden', false);
		$draftUse.prop('disabled', true);
		setDraftStatus('Drafting…');
		$draftHint.text('');
		new mw.Api().postWithToken('csrf', {
			action: 'ritchiegeneratesoldiersummary',
			title: pageTitle,
			format: 'json'
		}).done(function (data) {
			var draft = data.ritchiegeneratesoldiersummary || {};
			$draftArea.val(draft.text || '');
			$draftUse.prop('disabled', !(draft.text || '').trim());
			if (draft.source === 'ai') {
				$draftHint.text('Drafted with AI from this page’s facts and biography. Check every sentence before saving.');
			} else {
				$draftHint.text('Drafted from Personal details and the Biography section (no AI key configured). Edit as needed.');
			}
			setDraftStatus('');
		}).fail(function (code, data) {
			var err = (data && data.error && data.error.info) || code || 'Could not draft a summary';
			setDraftStatus(err, true);
			$draftUse.prop('disabled', true);
		});
	});

	$draftClose.on('click', function () {
		$draftPanel.prop('hidden', true);
		setDraftStatus('');
	});

	$draftUse.on('click', function () {
		var summary = $.trim($draftArea.val() || '');
		if (!summary) {
			setDraftStatus('The draft is empty.', true);
			return;
		}
		$draftUse.prop('disabled', true);
		setDraftStatus('Saving…');
		new mw.Api().postWithToken('csrf', {
			action: 'ritchieapplysoldiersummary',
			title: pageTitle,
			summary: summary,
			format: 'json'
		}).done(function () {
			setDraftStatus('Saved. Reloading…');
			window.location.reload();
		}).fail(function (code, data) {
			var err = (data && data.error && data.error.info) || code || 'Save failed';
			setDraftStatus(err, true);
			$draftUse.prop('disabled', false);
		});
	});
});

/**
 * Document OCR review: profile upload + Contribute documents inbox.
 * Proxies through action=ritchieocr (secret stays server-side). Nothing applies without review.
 */
$(function () {
	var loggedIn = !!mw.config.get('wgUserName');
	var pageTitle = (mw.config.get('wgPageName') || '').replace(/_/g, ' ');
	var isSoldier = mw.config.get('wgNamespaceNumber') === 0 && $('.soldier-infobox').length > 0;
	var isInbox = !!$('.ocr-inbox-slot').length;
	if (!isSoldier && !isInbox) {
		return;
	}

	function api() {
		return new mw.Api();
	}

	function ocrGet(params) {
		params = $.extend({ action: 'ritchieocr', format: 'json' }, params);
		return api().get(params).then(function (data) {
			return data.ritchieocr || data;
		});
	}

	function ocrPost(params, formData) {
		if (formData) {
			return api().postWithToken('csrf', formData, {
				contentType: 'multipart/form-data'
			}).then(function (data) {
				return data.ritchieocr || data;
			});
		}
		params = $.extend({ action: 'ritchieocr', format: 'json' }, params);
		return api().postWithToken('csrf', params).then(function (data) {
			return data.ritchieocr || data;
		});
	}

	function publicAssetUrl(path) {
		if (!path) {
			return '';
		}
		var jobMatch = String(path).match(/\/api\/jobs\/([a-fA-F0-9]+)\/preview(?:\?|$)/);
		var pageMatch = String(path).match(/\/api\/jobs\/([a-fA-F0-9]+)\/page\/(\d+)\/preview/);
		var params;
		if (pageMatch) {
			params = {
				ritchieocr_preview: '1',
				job_id: pageMatch[1],
				page: pageMatch[2]
			};
			return mw.util.wikiScript() + '?' + $.param(params);
		}
		if (jobMatch) {
			params = {
				ritchieocr_preview: '1',
				job_id: jobMatch[1]
			};
			return mw.util.wikiScript() + '?' + $.param(params);
		}
		/* Legacy fallback if a full absolute URL was stored */
		if (/^https?:\/\//i.test(path)) {
			return path;
		}
		var base = (window.__ocrPublicUrl || '').replace(/\/$/, '');
		if (base) {
			return base + path;
		}
		return '';
	}

	function publicPreviewUrl(job) {
		return publicAssetUrl(job && job.preview_url);
	}

	function timeAgo(ts) {
		var sec = Math.max(0, Math.floor(Date.now() / 1000 - Number(ts || 0)));
		if (!ts || sec < 60) {
			return 'just now';
		}
		if (sec < 3600) {
			return Math.floor(sec / 60) + 'm ago';
		}
		if (sec < 86400) {
			return Math.floor(sec / 3600) + 'h ago';
		}
		return Math.floor(sec / 86400) + 'd ago';
	}

	var DOC_TYPES = [
		{ id: 'naturalization', label: 'Naturalization / citizenship' },
		{ id: 'discharge', label: 'Discharge / separation' },
		{ id: 'enlistment', label: 'Enlistment / induction' },
		{ id: 'ritchie_cert', label: 'Camp Ritchie / MITC certificate' },
		{ id: 'team_roster', label: 'IPW / team roster (special orders)' },
		{ id: 'team_transfer', label: 'Team grouping / transfer (no team #)' },
		{ id: 'award', label: 'Award / citation' },
		{ id: 'newspaper', label: 'Newspaper / clipping' },
		{ id: 'other', label: 'Other document' }
	];

	/** MediaWiki JSON encodes PHP true as "" and false as null/omitted. */
	function mwApiBool(v) {
		if (v === true || v === 1 || v === '1' || v === 'true') {
			return true;
		}
		if (v === false || v === 0 || v === '0' || v === 'false' || v == null) {
			return false;
		}
		if (v === '') {
			return true;
		}
		return !!v;
	}

	function isMultiPersonJob(job) {
		if (!job) {
			return false;
		}
		if (mwApiBool(job.multi_person)) {
			return true;
		}
		var detected = job.people_summary && job.people_summary.detected;
		if (Number(detected) > 1) {
			return true;
		}
		var keys = {};
		(job.proposed || []).forEach(function (p) {
			if (p.person_key) {
				keys[p.person_key] = 1;
			}
		});
		return Object.keys(keys).length > 1;
	}

	function factAlreadyOnPage(fact) {
		return !!(fact && mwApiBool(fact.already_on_page));
	}

	function factSelected(fact) {
		return !!(fact && mwApiBool(fact.selected));
	}

	function changeLineForFact(fact) {
		var field = fact.field || '';
		var val = (fact.value || '').trim();
		var label = (fact.label || '').toLowerCase();
		if (factAlreadyOnPage(fact) && (field === 'ipw_ge' || field === 'teams')) {
			return 'Team already on their profile (' + (val || fact.existing_value || 'same') + ') — no change';
		}
		if (factAlreadyOnPage(fact)) {
			return (fact.label || 'Already on page') + ' — skipped';
		}
		if (field === 'ipw_ge') {
			return 'Set their Team (IPW) to ' + val;
		}
		if (field === 'teams') {
			return 'Add ' + val + ' to their Teams list';
		}
		if (field === 'asn') {
			return 'Set ASN to ' + val;
		}
		if (field === 'rank_final') {
			return 'Set rank to ' + val;
		}
		if (field === 'specialty') {
			return 'Set specialty/role to ' + val;
		}
		if (field === 'class_number') {
			return 'Set class to ' + val;
		}
		if (fact.kind === 'bio') {
			return 'Add to biography: ' + (val.length > 80 ? val.slice(0, 80) + '…' : val);
		}
		if (fact.kind === 'note') {
			return 'Add research note — ' + (fact.label || 'OCR note') + ': ' +
				(val.length > 90 ? val.slice(0, 90) + '…' : val);
		}
		if (label.indexOf('team') !== -1 || label.indexOf('ipw') !== -1) {
			return fact.label + (val ? ': ' + val : '');
		}
		return (fact.label || field || fact.kind) + (val ? ': ' + val : '');
	}

	function personDisplayName(fact) {
		var lab = fact.person_label || fact.target_title || 'Person';
		return lab.split('·')[0].trim().split('(')[0].trim() || lab;
	}

	function personMetaFromItems(items) {
		var rank = '';
		var role = '';
		var asn = '';
		var team = '';
		items.forEach(function (f) {
			if (f.field === 'rank_final' && f.value && !rank) {
				rank = f.value;
			}
			if (f.field === 'specialty' && f.value && !role) {
				role = f.value;
			}
			if (f.field === 'asn' && f.value && !asn) {
				asn = f.value;
			}
			if ((f.field === 'ipw_ge' || f.field === 'teams') && f.value && !team) {
				team = f.value.indexOf('IPW') === 0 ? f.value : ('IPW ' + f.value);
			}
		});
		return { rank: rank, role: role, asn: asn, team: team };
	}

	function groupFactsByPerson(proposed) {
		var order = [];
		var byPerson = {};
		proposed.forEach(function (fact) {
			var key = fact.person_key || (fact.target_title ? 'title:' + fact.target_title : '_single');
			if (!byPerson[key]) {
				byPerson[key] = [];
				order.push(key);
			}
			byPerson[key].push(fact);
		});
		return { order: order, byPerson: byPerson };
	}

	function isWritableFact(fact, applyNotes) {
		if (!fact) {
			return false;
		}
		if (fact.kind === 'field' || fact.kind === 'bio') {
			return true;
		}
		return !!(applyNotes && fact.kind === 'note');
	}

	/** Team membership is the main action on multi-person roster docs. */
	function isPrimaryTeamFact(fact) {
		return fact.field === 'ipw_ge' || fact.field === 'teams';
	}

	function profileUpdateSentence(fact) {
		if (!fact || !isPrimaryTeamFact(fact)) {
			return changeLineForFact(fact);
		}
		var val = (fact.value || '').trim();
		if (factAlreadyOnPage(fact)) {
			return 'Their profile already lists this team — nothing to write.';
		}
		if (fact.field === 'teams') {
			return 'On their soldier page, add ' + val + ' under Teams (they already have a different primary team).';
		}
		return 'On their soldier page, set Team to IPW ' + val + '.';
	}

	function bindSoldierTypeahead($input, $suggest, onPick) {
		var timer = null;
		$input.on('input', function () {
			var q = $.trim($input.val() || '');
			window.clearTimeout(timer);
			$suggest.empty().hide();
			if (q.length < 2) {
				return;
			}
			timer = window.setTimeout(function () {
				api().get({
					action: 'opensearch',
					search: q,
					namespace: 0,
					limit: 8,
					redirects: 'resolve'
				}).then(function (data) {
					var titles = ((data && data[1]) || []).filter(function (t) {
						return !rbIsMergedDuplicateTitle(t);
					});
					$suggest.empty();
					if (!titles.length) {
						$suggest.hide();
						return;
					}
					titles.forEach(function (t) {
						var $opt = $('<button>', { type: 'button', 'class': 'ocr-suggest-item', text: t });
						$opt.on('click', function () {
							onPick(t);
							$suggest.empty().hide();
						});
						$suggest.append($opt);
					});
					$suggest.show();
				});
			}, 220);
		});
	}

	function renderChangeRow(fact, job, opts) {
		opts = opts || {};
		var writable = isWritableFact(fact) && !factAlreadyOnPage(fact);
		var forceSelected = opts.forceSelected;
		var selected = writable && (forceSelected === undefined ? factSelected(fact) : !!forceSelected);
		var $row = $('<div>', {
			'class': 'ocr-fact-row' +
				(factAlreadyOnPage(fact) ? ' ocr-fact-already' : '') +
				(writable ? ' ocr-fact-writable' : ' ocr-fact-note') +
				(isPrimaryTeamFact(fact) ? ' ocr-fact-primary' : ' ocr-fact-secondary') +
				(fact.kind === 'note' ? ' ocr-fact-is-note' : ''),
			'data-id': fact.id,
			'data-person': fact.person_key || '',
			'data-field': fact.field || '',
			'data-kind': fact.kind || ''
		});
		var $check = $('<input>', {
			type: 'checkbox',
			checked: selected,
			disabled: !writable
		});
		var $line = $('<span>', { 'class': 'ocr-change-line' }).text(
			opts.plainSentence ? profileUpdateSentence(fact) : changeLineForFact(fact)
		);
		var $val = $('<input>', {
			type: 'text',
			'class': 'ocr-fact-value',
			value: fact.value || ''
		});
		var $tgt = $('<input>', {
			type: 'hidden',
			'class': 'ocr-fact-target',
			value: fact.target_title || job.bound_title || ''
		});
		var $details = $('<details>', { 'class': 'ocr-fact-details' });
		$details.append($('<summary>').text('Edit value'));
		$details.append($('<label>').text('Value ').append($val));
		$details.append($('<span>', { 'class': 'ocr-fact-meta' }).text(
			'field ' + (fact.field || fact.kind || '') + ' · conf ' + (fact.confidence || 0) + '%'
		));
		if (writable || factAlreadyOnPage(fact)) {
			$row.append($check, $line, $tgt, $details);
		} else {
			$row.append($line, $tgt, $details);
		}
		if (!writable) {
			$row.append($('<input>', { type: 'checkbox', checked: false, 'class': 'ocr-fact-check-hidden' }).hide());
		}
		return $row;
	}

	var $listRef = null;

	function renderPersonCards($list, proposed, job, onChange) {
		var grouped = groupFactsByPerson(proposed);
		var lastGroup = null;
		var $table = $('<table>', { 'class': 'ocr-roster-table' });
		var $thead = $('<thead>').append($('<tr>').append(
			$('<th>', { 'class': 'ocr-col-do' }).text('Write?'),
			$('<th>').text('Person on the scan'),
			$('<th>').text('Wiki soldier page'),
			$('<th>').text('What changes on that page')
		));
		var $tbody = $('<tbody>');
		$table.append($thead, $tbody);

		grouped.order.forEach(function (key) {
			var items = grouped.byPerson[key];
			var first = items[0] || {};
			var title = first.target_title || '';
			var meta = personMetaFromItems(items);
			var groupId = first.group_id || '';
			var teamFacts = items.filter(isPrimaryTeamFact);
			var secondary = items.filter(function (f) {
				return isWritableFact(f) && !isPrimaryTeamFact(f) && !factAlreadyOnPage(f);
			});
			var notes = items.filter(function (f) {
				return f.kind === 'note';
			});
			var teamFact = teamFacts[0];
			var teamDone = !!(teamFact && factAlreadyOnPage(teamFact));
			var teamReady = !!(teamFact && !factAlreadyOnPage(teamFact) && (teamFact.value || '').trim());
			var included = !!(title && teamReady && factSelected(teamFact));
			var teamLabel = (teamFact && (teamFact.value || '').trim())
				? ('IPW ' + String(teamFact.value).replace(/^IPW\s+/i, ''))
				: (meta.team || 'this team');

			if (groupId && groupId !== lastGroup) {
				lastGroup = groupId;
				var groupLabel = meta.team
					? ('Team ' + meta.team)
					: ('Group ' + groupId.replace(/^g/i, ''));
				var inGroup = grouped.order.filter(function (k) {
					return (grouped.byPerson[k][0] || {}).group_id === groupId;
				}).length;
				$tbody.append($('<tr>', { 'class': 'ocr-group-heading-row' }).append(
					$('<td>', { colspan: 4 }).text(
						groupLabel + ' — ' + inGroup + ' people on this document'
					)
				));
			}

			var $card = $('<tr>', {
				'class': 'ocr-person-card' +
					(title ? ' ocr-person-matched' : ' ocr-person-unresolved') +
					(teamDone ? ' ocr-person-done' : ''),
				'data-person': key
			});

			var $include = $('<input>', {
				type: 'checkbox',
				'class': 'ocr-include',
				checked: included,
				title: teamDone ? 'This team is already on their profile' : 'Write the team onto their soldier page'
			});
			if (teamDone || !teamFact) {
				$include.prop('disabled', true).prop('checked', false);
			}

			var $tgtShared = $('<input>', {
				type: 'text',
				'class': 'ocr-person-title',
				value: title,
				placeholder: 'Search surname or ASN to match a page…'
			});
			var $suggest = $('<div>', { 'class': 'ocr-suggest' }).hide();
			var $pageLink = $('<div>', { 'class': 'ocr-wiki-page-name' });
			var $matchStatus = $('<div>', { 'class': 'ocr-match-status' });
			var $outcome = $('<div>', { 'class': 'ocr-row-outcome' });

			function syncPrimaryChecks() {
				var on = $include.prop('checked') && !!$.trim($tgtShared.val() || '') && !teamDone;
				$card.find('.ocr-fact-primary.ocr-fact-writable input[type="checkbox"]').prop('checked', on);
				if (!on) {
					$card.find('.ocr-fact-secondary.ocr-fact-writable input[type="checkbox"]').prop('checked', false);
				}
			}

			function refreshOutcome() {
				var page = $.trim($tgtShared.val() || '');
				var extra = [];
				$card.find('.ocr-fact-secondary.ocr-fact-writable').each(function () {
					var $row = $(this);
					if ($row.find('input[type="checkbox"]').first().prop('checked')) {
						extra.push($row.find('.ocr-change-line').text());
					}
				});
				$outcome.removeClass(
					'ocr-outcome-write ocr-outcome-skip ocr-outcome-unmatched ocr-outcome-idle'
				);
				if (!page) {
					$include.prop('checked', false).prop('disabled', true);
					syncPrimaryChecks();
					$outcome.addClass('ocr-outcome-unmatched');
					$outcome.text(
						'No wiki page matched. Nothing will be written until you search and pick their soldier page' +
						(teamFact && teamFact.value ? (', then Team ' + teamLabel + ' can be added.') : '.')
					);
					return;
				}
				if (!teamFact) {
					$include.prop('disabled', true).prop('checked', false);
					if (extra.length) {
						$outcome.addClass('ocr-outcome-write');
						var $ul0 = $('<ul>', { 'class': 'ocr-row-write-list' });
						extra.forEach(function (line) {
							$ul0.append($('<li>').text(line));
						});
						$outcome.empty().append($ul0);
					} else {
						$outcome.addClass('ocr-outcome-idle');
						$outcome.text(
							'Matched. This scan has no team number to write. Rank/ASN/role stay as cues unless you open extra fields.'
						);
					}
					return;
				}
				if (teamDone && !extra.length) {
					$include.prop('disabled', true).prop('checked', false);
					$outcome.addClass('ocr-outcome-skip');
					$outcome.empty().append(
						document.createTextNode('Already on their profile: Team ' + teamLabel + '. Nothing new will be written.')
					);
					return;
				}
				$include.prop('disabled', false);
				if ($include.prop('checked') || extra.length) {
					$outcome.addClass('ocr-outcome-write');
					var $ul = $('<ul>', { 'class': 'ocr-row-write-list' });
					if ($include.prop('checked') && teamReady) {
						$ul.append($('<li>').text('Set Team to ' + teamLabel + ' on that soldier page'));
					}
					extra.forEach(function (line) {
						$ul.append($('<li>').text(line));
					});
					$outcome.empty().append($ul);
					return;
				}
				$outcome.addClass('ocr-outcome-idle');
				$outcome.text(
					'Matched, but not selected. Check “Write?” to add Team ' + teamLabel + ' to that soldier page.'
				);
			}

			function setMatch(t, silent) {
				title = t || '';
				$tgtShared.val(title);
				$card.find('.ocr-fact-target').val(title);
				$pageLink.empty();
				if (title) {
					$card.removeClass('ocr-person-unresolved').addClass('ocr-person-matched');
					$pageLink.append($('<a>', {
						href: mw.util.getUrl(title),
						text: title,
						target: '_blank'
					}));
					$matchStatus.text('Open the page in a new tab to see their current profile.');
				} else {
					$card.removeClass('ocr-person-matched').addClass('ocr-person-unresolved');
					$pageLink.append($('<span>', { 'class': 'ocr-wiki-missing' }).text('Not matched to a soldier page'));
					$matchStatus.text('Type a surname or ASN below, then pick the page.');
					$include.prop('checked', false);
					syncPrimaryChecks();
				}
				refreshOutcome();
				if (!silent && onChange) {
					onChange();
				}
			}

			var $do = $('<td>', { 'class': 'ocr-col-do' });
			$do.append($include);

			var $who = $('<td>', { 'class': 'ocr-col-who' });
			$who.append($('<div>', { 'class': 'ocr-scan-name' }).text(personDisplayName(first)));
			var whoBits = [];
			if (meta.rank) {
				whoBits.push(meta.rank);
			}
			if (meta.role) {
				whoBits.push(meta.role);
			}
			if (meta.asn) {
				whoBits.push('ASN ' + meta.asn);
			}
			if (whoBits.length) {
				$who.append($('<div>', { 'class': 'ocr-scan-bits' }).text(
					'From the scan (not written): ' + whoBits.join(' · ')
				));
			}

			var $page = $('<td>', { 'class': 'ocr-col-page' });
			$page.append($pageLink, $matchStatus, $tgtShared, $suggest);
			bindSoldierTypeahead($tgtShared, $suggest, setMatch);
			$tgtShared.on('change', function () {
				setMatch($.trim($tgtShared.val() || ''));
			});
			var candidates = first.match_candidates || [];
			items.forEach(function (f) {
				if ((!candidates || !candidates.length) && f.match_candidates && f.match_candidates.length) {
					candidates = f.match_candidates;
				}
			});
			if (candidates && candidates.length) {
				var $pick = $('<select>', { 'class': 'ocr-match-pick' });
				$pick.append($('<option>', { value: '', text: 'Suggested wiki pages…' }));
				candidates.forEach(function (t) {
					$pick.append($('<option>', { value: t, text: t, selected: t === title }));
				});
				$pick.on('change', function () {
					if ($pick.val()) {
						setMatch($pick.val());
					}
				});
				$page.append($pick);
			}

			var $action = $('<td>', { 'class': 'ocr-col-action' });
			$action.append($outcome);

			var $store = $('<div>', { 'class': 'ocr-fact-store' });
			if (teamFact) {
				var $teamRow = renderChangeRow(teamFact, job, {
					plainSentence: true,
					forceSelected: included && !teamDone
				});
				$teamRow.find('.ocr-fact-target').val(title);
				$teamRow.find('input[type="checkbox"]').first().addClass('ocr-primary-check');
				$store.append($teamRow);
			}
			notes.forEach(function (fact) {
				var $nrow = renderChangeRow(fact, job, {});
				$nrow.find('.ocr-fact-target').val(title);
				$store.append($nrow);
			});
			$action.append($store);

			if (secondary.length) {
				var $moreFields = $('<details>', { 'class': 'ocr-card-more' });
				$moreFields.append($('<summary>').text(
					'Also write rank / ASN / role (' + secondary.length + ') — off unless you check them'
				));
				secondary.forEach(function (fact) {
					var $row = renderChangeRow(fact, job, { forceSelected: false });
					$row.find('.ocr-fact-target').val(title);
					$moreFields.append($row);
				});
				$action.append($moreFields);
			}

			$include.on('change', function () {
				if ($include.prop('checked') && !$.trim($tgtShared.val() || '')) {
					$include.prop('checked', false);
					$matchStatus.text('Match a soldier page first, then check Write?.');
					refreshOutcome();
					return;
				}
				if ($include.prop('checked') && teamDone) {
					$include.prop('checked', false);
					refreshOutcome();
					return;
				}
				syncPrimaryChecks();
				refreshOutcome();
				if (onChange) {
					onChange();
				}
			});

			$card.append($do, $who, $page, $action);
			$card.on('change', 'input[type="checkbox"], input.ocr-fact-value', function (ev) {
				if ($(ev.target).hasClass('ocr-include')) {
					return;
				}
				refreshOutcome();
				if (onChange) {
					onChange();
				}
			});
			$tbody.append($card);
			setMatch(title, true);
		});

		$list.append($table);
	}

	function renderSingleChangeCard($list, proposed, job, onChange) {
		var $card = $('<div>', { 'class': 'ocr-person-card ocr-person-matched ocr-single-card' });
		var title = job.bound_title || '';
		$card.append($('<div>', { 'class': 'ocr-card-who-label' }).text('Changes for this soldier page'));
		$card.append($('<div>', { 'class': 'ocr-card-name' }).text(title || 'This soldier'));
		if (title) {
			$card.append($('<div>', { 'class': 'ocr-match-status' }).append(
				$('<a>', { href: mw.util.getUrl(title), text: title, target: '_blank' })
			));
		}
		var $tgtHidden = $('<input>', { type: 'hidden', 'class': 'ocr-person-title', value: title });
		$card.append($tgtHidden);

		var primary = proposed.filter(function (f) {
			return isWritableFact(f) && !factAlreadyOnPage(f);
		});
		var skipped = proposed.filter(function (f) { return factAlreadyOnPage(f); });
		var notes = proposed.filter(function (f) { return f.kind === 'note'; });

		var $changes = $('<div>', { 'class': 'ocr-card-changes' });
		if (!primary.length) {
			$changes.append($('<p>', { 'class': 'ocr-fact-meta' }).text('No new fields to write (or all already on the page).'));
		}
		primary.forEach(function (fact) {
			var $row = renderChangeRow(fact, job, { hideTargetVisible: true, plainSentence: true });
			$row.find('.ocr-fact-target').val(title);
			$changes.append($row);
		});
		if (skipped.length) {
			var $skip = $('<details>', { 'class': 'ocr-card-more' });
			$skip.append($('<summary>').text('Already on page (' + skipped.length + ')'));
			skipped.forEach(function (fact) {
				$skip.append(renderChangeRow(fact, job, { hideTargetVisible: true }));
			});
			$changes.append($skip);
		}
		$card.append($changes);

		if (notes.length) {
			var $more = $('<details>', { 'class': 'ocr-card-more' });
			$more.append($('<summary>').text('OCR notes (not written to the page)'));
			notes.forEach(function (fact) {
				var $row = renderChangeRow(fact, job, { hideTargetVisible: true });
				$row.find('.ocr-fact-target').val(title);
				$more.append($row);
			});
			$card.append($more);
		}
		$card.on('change', 'input', function () {
			if (onChange) {
				onChange();
			}
		});
		$list.append($card);
	}

	function renderApplySuccess($host, job) {
		$host.empty();
		var $panel = $('<div>', { 'class': 'ocr-review-panel ocr-apply-success' });
		$panel.append($('<p>').append($('<strong>').text('Applied')));
		var log = job.apply_log || [];
		var titles = {};
		log.forEach(function (line) {
			var m = /^([^:]+):\s*(.+)$/.exec(line);
			if (m && m[1].indexOf('team stub') < 0) {
				titles[m[1]] = titles[m[1]] || [];
				titles[m[1]].push(m[2]);
			}
		});
		var keys = Object.keys(titles);
		if (keys.length) {
			var $ul = $('<ul>', { 'class': 'ocr-apply-links' });
			keys.forEach(function (t) {
				var $li = $('<li>');
				$li.append($('<a>', { href: mw.util.getUrl(t), text: t }));
				$li.append($('<span>', { 'class': 'ocr-fact-meta' }).text(' — ' + titles[t].join('; ')));
				$ul.append($li);
			});
			$panel.append($ul);
		} else {
			$panel.append($('<p>').text(log.join(' · ') || 'Done.'));
		}
		if (isSoldier) {
			$panel.append($('<p>').append(
				$('<button>', { type: 'button', text: 'Reload page' }).on('click', function () {
					window.location.reload();
				}),
				document.createTextNode(' to see updates on this profile.')
			));
		}
		$host.append($panel);
	}

	function renderReview($host, job, cfg) {
		$host.empty();
		var $panel = $('<div>', { 'class': 'ocr-review-panel ocr-review-v2' + (isMultiPersonJob(job) ? ' ocr-review-roster' : '') });
		if (isMultiPersonJob(job)) {
			$panel.append($('<p>').append(
				$('<strong>').text('Each row is one person'),
				document.createTextNode(
					' — the last column is the only thing that would be written on a soldier page. Rank, role, and ASN on the left are from the scan (for matching). Nothing is saved until Apply.'
				)
			));
		} else {
			$panel.append($('<p>').append(
				$('<strong>').text('Confirm changes, then Apply'),
				document.createTextNode(' — nothing is saved until you click Apply.')
			));
		}
		if (job.source_kind === 'facebook_seed') {
			$panel.append($('<div>', { 'class': 'ocr-seed-banner' }).append(
				$('<strong>').text('Facebook OCR seed'),
				document.createTextNode(
					' — review #' + (job.source_ref || '?') +
					'. Text is from a prior gallery OCR; verify against the File on the soldier page before Apply.'
				)
			));
		}

		if (isMultiPersonJob(job) && job.people_summary) {
			var ps = job.people_summary;
			var $banner = $('<div>', { 'class': 'ocr-outcome-banner' });
			$banner.append($('<strong>').text(
				(ps.detected || 0) + ' people on this document'
			));
			var bannerBits = [];
			bannerBits.push((ps.matched || 0) + ' matched to a wiki page');
			bannerBits.push((ps.unresolved || 0) + ' not matched yet');
			if (ps.already_on_page) {
				bannerBits.push((ps.already_on_page) + ' already have this team on their profile (nothing to write)');
			}
			if (ps.inferred_teams && ps.inferred_teams.length) {
				bannerBits.push('teams ' + ps.inferred_teams.join(', '));
			} else if (ps.teams && ps.teams.length) {
				bannerBits.push('teams ' + ps.teams.join(', '));
			}
			$banner.append($('<div>').text(bannerBits.join(' · ')));
			$panel.append($banner);
		} else {
			var engineBit = job.ocr_engine ? (' · Engine: ' + job.ocr_engine) : '';
			$panel.append($('<p>', { 'class': 'ocr-fact-meta' }).text(
				(job.doc_type_label || 'Document') +
				' · OCR quality ' + (job.ocr_quality || 0) + '%' +
				engineBit +
				(job.error ? (' · Error: ' + job.error) : '')
			));
			if (job.ocr_engine_notes && job.ocr_engine_notes.length) {
				$panel.append($('<p>', { 'class': 'ocr-fact-meta' }).text(
					job.ocr_engine_notes.slice(0, 3).join(' · ')
				));
			}
		}

		if (job.identity_warning) {
			var $id = $('<div>', {
				'class': 'ocr-identity-banner' + (mwApiBool(job.identity_ok) ? ' ocr-identity-ok' : ' ocr-identity-warn')
			});
			$id.append($('<strong>').text(mwApiBool(job.identity_ok) ? 'Identity check: ' : 'Identity warning: '));
			$id.append(document.createTextNode(job.identity_warning));
			$panel.append($id);
		}

		if (mwApiBool(job.pdf_truncated)) {
			$panel.append($('<div>', { 'class': 'ocr-identity-banner ocr-identity-warn' }).append(
				$('<strong>').text('PDF truncated: '),
				document.createTextNode(
					'Only the first ' + (job.page_count || 8) + ' of ' +
					(job.pdf_total_pages || '?') +
					' pages were OCR’d. Split the PDF or re-upload the remaining pages.'
				)
			));
		}

		if (job.error) {
			$panel.append($('<div>', { 'class': 'ocr-identity-banner ocr-identity-warn' }).text(job.error));
		}

		var $layout = $('<div>', { 'class': 'ocr-review-layout' });
		var $prev = $('<div>', { 'class': 'ocr-review-preview ocr-preview-sticky' });
		var pages = job.pages || [];
		if (pages.length > 1) {
			$prev.append($('<p>').append($('<strong>').text('Pages')));
			var $pageList = $('<div>', { 'class': 'ocr-page-picker' });
			pages.forEach(function (pg) {
				var $pg = $('<label>', { 'class': 'ocr-page-item', 'data-index': pg.index });
				var $cb = $('<input>', { type: 'checkbox', checked: pg.selected !== false });
				var imgUrl = publicAssetUrl(pg.preview_url);
				if (imgUrl) {
					$pg.append($('<img>', { src: imgUrl, alt: 'Page ' + (pg.index + 1) }));
				}
				$pg.append($cb, document.createTextNode(' Page ' + (pg.index + 1)));
				$pageList.append($pg);
			});
			$prev.append($pageList);
		} else {
			var prevUrl = publicPreviewUrl(job);
			if (prevUrl) {
				$prev.append($('<img>', { src: prevUrl, alt: 'Document preview' }));
			} else {
				$prev.append($('<p>').text('Preview available after OCR finishes.'));
			}
		}

		var $facts = $('<div>', { 'class': 'ocr-review-facts' });
		var proposed = job.proposed || [];
		var $list = $('<div>', { 'class': 'ocr-fact-groups ocr-card-list' });
		$listRef = $list;

		var $previewBox = $('<div>', { 'class': 'ocr-apply-preview' });
		$previewBox.append($('<strong>').text('Soldier pages that will be edited'));
		$previewBox.append($('<p>', { 'class': 'ocr-fact-meta ocr-preview-hint' }).text(
			isMultiPersonJob(job)
				? 'Grouped by wiki page. Rank/role/ASN from the scan are not listed here unless you check them under a row.'
				: 'Checked items below will be written on Apply.'
		));
		var $previewCount = $('<p>', { 'class': 'ocr-preview-count' });
		var $previewUl = $('<ul>', { 'class': 'ocr-apply-preview-list' });
		$previewBox.append($previewCount, $previewUl);

		function refreshPreview() {
			var applyNotesOn = !!$panel.find('.ocr-apply-notes input').prop('checked');
			var props = collectProposed();
			var byPage = {};
			props.forEach(function (p) {
				if (!p.selected || !isWritableFact(p, applyNotesOn) || factAlreadyOnPage(p)) {
					return;
				}
				var page = (p.target_title || '').trim();
				if (!page) {
					return;
				}
				if (!byPage[page]) {
					byPage[page] = [];
				}
				byPage[page].push(p);
			});
			var pages = Object.keys(byPage);
			$previewUl.empty();
			if (!pages.length) {
				$previewCount.text('');
				$previewUl.append($('<li>', { 'class': 'ocr-fact-meta' }).text(
					isMultiPersonJob(job)
						? 'Nothing will be written on Apply. Check Write? on a row (after matching a wiki page) to add that person.'
						: 'Nothing selected yet.'
				));
			} else {
				$previewCount.text(
					'Apply will edit ' + pages.length + ' soldier page' + (pages.length === 1 ? '' : 's') + ':'
				);
				pages.forEach(function (page) {
					var $li = $('<li>', { 'class': 'ocr-preview-person' });
					$li.append($('<a>', { href: mw.util.getUrl(page), text: page, target: '_blank' }));
					var $sub = $('<ul>');
					byPage[page].forEach(function (p) {
						$sub.append($('<li>').text(profileUpdateSentence(p)));
					});
					$li.append($sub);
					$previewUl.append($li);
				});
			}
		}

		$facts.append($previewBox);
		if (isMultiPersonJob(job)) {
			var $toolbar = $('<div>', { 'class': 'ocr-multi-toolbar' });
			var $selMatched = $('<button>', { type: 'button', text: 'Write team for every matched person who still needs it' });
			var $deselUnres = $('<button>', { type: 'button', text: 'Clear unmatched' });
			$toolbar.append($selMatched, $deselUnres);
			$facts.append($toolbar);
			renderPersonCards($list, proposed, job, refreshPreview);
			$selMatched.on('click', function () {
				$list.find('.ocr-person-matched:not(.ocr-person-done)').each(function () {
					var $card = $(this);
					$card.find('.ocr-include').prop('disabled', false).prop('checked', true).trigger('change');
				});
				refreshPreview();
			});
			$deselUnres.on('click', function () {
				$list.find('.ocr-person-unresolved .ocr-include').prop('checked', false).trigger('change');
				refreshPreview();
			});
		} else {
			renderSingleChangeCard($list, proposed, job, refreshPreview);
		}
		$facts.append($list);

		/* Advanced: OCR text, doc type, gallery, overwrite, rebuild */
		var $adv = $('<details>', { 'class': 'ocr-advanced' });
		$adv.append($('<summary>').text('Advanced'));
		var $advBody = $('<div>', { 'class': 'ocr-advanced-body' });

		var $docType = $('<label>', { 'class': 'ocr-doc-type' }).text('Document type ');
		var $docSelect = $('<select>');
		DOC_TYPES.forEach(function (dt) {
			$docSelect.append($('<option>', {
				value: dt.id,
				text: dt.label,
				selected: (job.doc_type || 'other') === dt.id
			}));
		});
		$docType.append($docSelect);
		$advBody.append($docType);

		$advBody.append($('<label>').text('Gallery caption'));
		var $caption = $('<input>', {
			type: 'text',
			'class': 'ocr-gallery-caption',
			value: job.gallery_caption || ''
		});
		$advBody.append($caption);

		$advBody.append($('<label>').text('OCR text (editable)'));
		var $text = $('<textarea>').val(job.ocr_text || '');
		$advBody.append($text);

		var $overwrite = $('<label>').append(
			$('<input>', { type: 'checkbox', checked: mwApiBool(job.overwrite) }),
			' Overwrite existing infobox fields (default: fill empty only)'
		);
		var $gallery = $('<label>').append(
			$('<input>', { type: 'checkbox', checked: mwApiBool(job.add_to_gallery) }),
			' Add document to Gallery on Apply' + (isMultiPersonJob(job) ? ' (off by default for multi-person)' : '')
		);
		var $applyNotes = $('<label>', { 'class': 'ocr-apply-notes' }).append(
			$('<input>', { type: 'checkbox', checked: mwApiBool(job.apply_notes) }),
			' Also write selected notes into Research notes'
		);
		var $forceMulti = $('<label>', { 'class': 'ocr-force-multi' }).append(
			$('<input>', { type: 'checkbox', checked: mwApiBool(job.force_multi) }),
			' Treat as multi-person roster (rebuild required)'
		);
		if (isMultiPersonJob(job) && !job.bound_title) {
			$forceMulti.hide();
		}
		var $forceVision = $('<label>', { 'class': 'ocr-force-vision' }).append(
			$('<input>', { type: 'checkbox', checked: mwApiBool(job.force_vision) }),
			' Handwriting / hard scan (print OCR first; paid vision only if weak)'
		);
		if (!cfg.llmVision) {
			$forceVision.append(document.createTextNode(' — needs RITCHIE_LLM_API_KEY on OCR service'));
		}
		var $forceId = $('<label>', { 'class': 'ocr-force-identity' }).append(
			$('<input>', { type: 'checkbox', checked: false }),
			' I confirmed this document matches this soldier (override identity guard)'
		);
		if (mwApiBool(job.identity_ok) || isMultiPersonJob(job)) {
			$forceId.hide();
		}
		$applyNotes.find('input').on('change', refreshPreview);
		$advBody.append(
			$overwrite, $('<br>'),
			$gallery, $('<br>'),
			$applyNotes, $('<br>'),
			$forceMulti, $('<br>'),
			$forceVision, $('<br>'),
			$forceId
		);

		var $save = $('<button>', { type: 'button', text: 'Save draft' });
		var $rebuild = $('<button>', { type: 'button', text: 'Rebuild facts / re-OCR if handwriting checked' });
		$advBody.append($('<div>', { 'class': 'ocr-advanced-actions' }).append($save, $rebuild));
		$adv.append($advBody);
		$facts.append($adv);

		$layout.append($prev, $facts);
		$panel.append($layout);

		var $actions = $('<div>', { 'class': 'ocr-review-actions' });
		var $discard = $('<button>', { type: 'button', text: 'Discard' });
		var $dry = $('<button>', { type: 'button', text: 'Preview Apply' });
		var $apply = $('<button>', {
			type: 'button',
			'class': 'ocr-apply-btn',
			text: 'Apply selected'
		});
		var $status = $('<p>', { 'class': 'ocr-review-status' });
		if (!cfg.canApply) {
			$apply.prop('disabled', true);
			$dry.prop('disabled', true);
			$status.text('Apply requires the ocr-reviewer group (or admin). Your upload stays in the review queue.');
		}
		$actions.append($discard, $dry, $apply, $status);
		$panel.append($actions);
		$host.append($panel);
		refreshPreview();

		function collectProposed() {
			var applyNotesOn = !!$applyNotes.find('input').prop('checked');
			var out = [];
			$list.find('.ocr-fact-row').each(function () {
				var $row = $(this);
				var id = $row.attr('data-id');
				var base = null;
				proposed.forEach(function (p) {
					if (p.id === id) {
						base = $.extend({}, p);
					}
				});
				if (!base) {
					return;
				}
				var $cb = $row.find('input[type="checkbox"]').first();
				base.selected = $cb.length ? !!$cb.prop('checked') : false;
				if (base.kind === 'note' && !applyNotesOn) {
					base.selected = false;
				}
				base.value = $.trim($row.find('.ocr-fact-value').val() || base.value || '');
				var $card = $row.closest('.ocr-person-card');
				var $t = $card.find('.ocr-person-title');
				if ($t.length) {
					base.target_title = $.trim($t.val() || '');
				} else {
					base.target_title = $.trim($row.find('.ocr-fact-target').val() || job.bound_title || '');
				}
				$row.find('.ocr-fact-target').val(base.target_title);
				out.push(base);
			});
			return out;
		}

		function collectPages() {
			var out = [];
			$prev.find('.ocr-page-item').each(function () {
				var $pg = $(this);
				out.push({
					index: parseInt($pg.attr('data-index'), 10),
					selected: $pg.find('input[type="checkbox"]').prop('checked')
				});
			});
			return out;
		}

		function payload() {
			return {
				ocr_text: $text.val(),
				proposed: collectProposed(),
				overwrite: $overwrite.find('input').prop('checked'),
				add_to_gallery: $gallery.find('input').prop('checked'),
				pages: collectPages(),
				doc_type: $docSelect.val(),
				gallery_caption: $.trim($caption.val() || ''),
				force_identity: $forceId.find('input').prop('checked'),
				force_multi: $forceMulti.find('input').prop('checked'),
				force_vision: $forceVision.find('input').prop('checked'),
				apply_notes: $applyNotes.find('input').prop('checked')
			};
		}

		$save.on('click', function () {
			$status.text('Saving draft…');
			ocrPost({
				op: 'draft',
				job_id: job.id,
				payload: JSON.stringify(payload())
			}).then(function () {
				$status.text('Draft saved.');
			}).catch(function (err) {
				$status.text(String(err));
			});
		});

		$rebuild.on('click', function () {
			$status.text('Rebuilding facts from selected pages…');
			$rebuild.prop('disabled', true);
			ocrPost({
				op: 'rebuild',
				job_id: job.id,
				payload: JSON.stringify(payload())
			}).then(function (resp) {
				renderReview($host, resp.job || resp, cfg);
			}).catch(function (err) {
				$status.text(String(err && err.error && err.error.info || err));
				$rebuild.prop('disabled', false);
			});
		});

		$docSelect.on('change', function () {
			$status.text('Updating document type…');
			ocrPost({
				op: 'draft',
				job_id: job.id,
				payload: JSON.stringify(payload())
			}).then(function (resp) {
				renderReview($host, resp.job || resp, cfg);
			}).catch(function (err) {
				$status.text(String(err));
			});
		});

		$discard.on('click', function () {
			if (!window.confirm('Discard this OCR job?')) {
				return;
			}
			ocrPost({ op: 'discard', job_id: job.id }).then(function () {
				$host.empty().append($('<p>').text('Discarded.'));
			});
		});

		$dry.on('click', function () {
			$status.text('Building Apply preview…');
			$dry.prop('disabled', true);
			ocrPost({
				op: 'dryrun',
				job_id: job.id,
				payload: JSON.stringify(payload())
			}).then(function (resp) {
				$dry.prop('disabled', false);
				var plan = resp.plan || {};
				var pages = plan.pages || [];
				var $box = $('<div>', { 'class': 'ocr-dry-run' });
				$box.append($('<strong>').text(
					'Apply preview — would edit ' + (plan.will_edit || 0) +
					' of ' + (plan.page_count || 0) + ' page(s)'
				));
				var $ul = $('<ul>');
				pages.forEach(function (pg) {
					var $li = $('<li>');
					$li.append($('<a>', {
						href: mw.util.getUrl(pg.title),
						text: pg.title,
						target: '_blank'
					}));
					$li.append(document.createTextNode(' — ' + (pg.status || 'planned')));
					if (pg.changes && pg.changes.length) {
						var $sub = $('<ul>');
						pg.changes.forEach(function (c) {
							$sub.append($('<li>').text(c));
						});
						$li.append($sub);
					}
					$ul.append($li);
				});
				$box.append($ul);
				$status.empty().append($box);
			}).catch(function (err) {
				$dry.prop('disabled', false);
				$status.text(String(err && err.error && err.error.info || err));
			});
		});

		$apply.on('click', function () {
			if (!isMultiPersonJob(job) && !mwApiBool(job.identity_ok) && !$forceId.find('input').prop('checked')) {
				$status.text('Confirm identity (Advanced → checkbox) before Apply, or fix the wrong document.');
				$adv.prop('open', true);
				return;
			}
			var selected = collectProposed().filter(function (p) {
				return p.selected && isWritableFact(p, $applyNotes.find('input').prop('checked')) && (p.target_title || '').trim();
			});
			if (!selected.length) {
				$status.text('Select at least one change with a matched soldier.');
				return;
			}
			var nPages = {};
			selected.forEach(function (p) { nPages[p.target_title] = 1; });
			var pageCount = Object.keys(nPages).length;
			if (pageCount > 5 && !window.confirm('Apply changes to ' + pageCount + ' soldier pages?')) {
				return;
			}
			$status.text('Applying…');
			$apply.prop('disabled', true);
			ocrPost({
				op: 'apply',
				job_id: job.id,
				payload: JSON.stringify(payload())
			}).then(function (resp) {
				renderApplySuccess($host, resp.job || resp);
			}).catch(function (err) {
				$status.text(String(err && err.error && err.error.info || err));
				$apply.prop('disabled', false);
			});
		});
	}

	function pollJob($host, jobId, cfg) {
		$host.empty();
		var $status = $('<p>', { 'class': 'ocr-review-status' }).text('Running OCR…');
		var $barWrap = $('<div>', { 'class': 'ocr-progress-wrap' });
		var $bar = $('<div>', { 'class': 'ocr-progress-bar' });
		$barWrap.append($bar);
		var $cancel = $('<button>', { type: 'button', text: 'Cancel' });
		$host.append($status, $barWrap, $cancel);
		var tries = 0;
		var cancelled = false;

		$cancel.on('click', function () {
			cancelled = true;
			$cancel.prop('disabled', true);
			$status.text('Cancelling…');
			ocrPost({ op: 'cancel', job_id: jobId }).then(function () {
				$host.empty().append($('<p>').text('OCR cancelled.'));
			}).catch(function (err) {
				$host.empty().append($('<p>').text(String(err)));
			});
		});

		function tick() {
			if (cancelled) {
				return;
			}
			tries += 1;
			ocrGet({ op: 'job', job_id: jobId }).then(function (resp) {
				var job = resp.job || resp;
				if (job.status === 'discarded') {
					$host.empty().append($('<p>').text('OCR cancelled.'));
					return;
				}
				if (job.status === 'queued' || job.status === 'processing') {
					if (tries > 180) {
						$host.empty().append($('<p>').text('OCR timed out. Open it later from the review queue.'));
						return;
					}
					var pct = typeof job.progress === 'number' ? job.progress : 0;
					$bar.css('width', pct + '%');
					$status.text(
						(job.progress_message || 'Running OCR…') +
						' (' + pct + '%)'
					);
					window.setTimeout(tick, 1500);
					return;
				}
				if (job.status === 'error') {
					$host.empty().append($('<p>').text('OCR error: ' + (job.error || 'unknown')));
					return;
				}
				renderReview($host, job, cfg);
			}).catch(function (err) {
				$host.empty().append($('<p>').text(String(err)));
			});
		}
		tick();
	}

	function startUpload($fileInput, boundTitle, $host, cfg, opts) {
		opts = opts || {};
		var file = $fileInput[0].files[0];
		if (!file) {
			$host.text('Choose a file first.');
			return;
		}
		$host.empty().append($('<p>').text('Uploading for OCR…'));
		var fd = {
			action: 'ritchieocr',
			op: 'upload',
			bound_title: boundTitle || '',
			force_multi: opts.forceMulti ? true : false,
			force_vision: opts.forceVision ? true : false,
			file: file,
			format: 'json'
		};
		ocrPost(null, fd).then(function (resp) {
			var job = (resp.job || resp);
			if (!job || !job.id) {
				$host.empty().append($('<p>').text('OCR upload failed: no job id returned.'));
				return;
			}
			pollJob($host, job.id, cfg);
		}).catch(function (err) {
			var msg = err;
			if (err && err.error && err.error.info) {
				msg = err.error.info;
			} else if (typeof err === 'string') {
				msg = err;
			} else if (err && err.message) {
				msg = err.message;
			} else {
				try {
					msg = JSON.stringify(err);
				} catch (e) {
					msg = String(err);
				}
			}
			$host.empty().append($('<p>').text('OCR upload failed: ' + msg));
		});
	}

	if (!loggedIn) {
		var $need = $('<p>').html(
			'<a href="' + mw.util.getUrl('Special:UserLogin', { returnto: pageTitle }) + '">Log in</a> to upload documents for OCR review.'
		);
		if (isInbox) {
			$('.ocr-inbox-slot').first().append($need);
		}
		/* Soldier pages already show a combined login note in .soldier-upload-box. */
		return;
	}

	ocrGet({ op: 'config' }).then(function (cfg) {
		window.__ocrPublicUrl = cfg.publicUrl || '';

		if (isSoldier) {
			var $section = $('<div>', { 'class': 'soldier-contrib-section soldier-contrib-ocr' });
			$section.append($('<p>', { 'class': 'soldier-contrib-heading' }).append(
				$('<strong>').text('OCR a document to enrich this profile')
			));
			$section.append($('<p>', { 'class': 'soldier-contrib-help' }).text(
				'Scan a form, letter, certificate, clipping, or roster. Review proposes infobox facts and a short biography draft — nothing is written until Apply. Team rosters can update many soldiers at once.'
			));
			var $file = $('<input>', {
				type: 'file',
				accept: '.jpg,.jpeg,.png,.gif,.webp,.tif,.tiff,.pdf',
				'class': 'soldier-upload-file'
			});
			var $rosterMode = $('<label>', { 'class': 'ocr-force-multi-upload' }).append(
				$('<input>', { type: 'checkbox' }),
				' This is a multi-person roster (not only this soldier)'
			);
			var $visionMode = $('<label>', { 'class': 'ocr-force-multi-upload' }).append(
				$('<input>', { type: 'checkbox' }),
				' Handwriting / hard scan (free print OCR first; vision only if needed)'
			);
			var $btn = $('<button>', { type: 'button', text: 'Upload & OCR' });
			var $host = $('<div>', { 'class': 'ocr-review-host' });
			$section.append($file, $rosterMode, $visionMode, $btn, $host);
			$btn.on('click', function () {
				startUpload($file, pageTitle, $host, cfg, {
					forceMulti: $rosterMode.find('input').prop('checked'),
					forceVision: $visionMode.find('input').prop('checked')
				});
			});
			var $slot = $('.soldier-ocr-slot').first();
			if (!$slot.length) {
				/* Contribute box not ready yet (rare); wait one tick. */
				window.setTimeout(function () {
					var $retry = $('.soldier-ocr-slot').first();
					if ($retry.length) {
						$retry.empty().append($section);
					}
				}, 0);
			} else {
				$slot.empty().append($section);
			}
		}

		if (isInbox) {
			var $slot = $('.ocr-inbox-slot').first();
			$slot.empty();
			var $up = $('<div>', { 'class': 'ocr-review-box' });
			$up.append($('<h3>').text('Upload a document'));
			$up.append($('<p>').text(
				'Enrich soldier profiles from scans: propose infobox facts, a short biography draft, and Sources. For multi-person IPW rosters, review is a table (usually Team membership). Rank, role, and ASN are matching cues until you approve.'
			));
			var $file2 = $('<input>', { type: 'file', accept: '.jpg,.jpeg,.png,.gif,.webp,.tif,.tiff,.pdf' });
			var $vision2 = $('<label>', { 'class': 'ocr-force-multi-upload' }).append(
				$('<input>', { type: 'checkbox' }),
				' Handwriting / hard scan (vision only if print OCR is weak)'
			);
			var $btn2 = $('<button>', { type: 'button', text: 'Upload & OCR' });
			var $host2 = $('<div>', { 'class': 'ocr-review-host' });
			$up.append($file2, $vision2, ' ', $btn2, $host2);
			$btn2.on('click', function () {
				startUpload($file2, '', $host2, cfg, {
					forceVision: $vision2.find('input').prop('checked')
				});
			});
			$slot.append($up);

			var $queue = $('<div>', { 'class': 'ocr-review-box' });
			$queue.append($('<h3>').text('Review queue'));
			$queue.append($('<p>', { 'class': 'ocr-fact-meta' }).text(
				'Resume ready jobs, or reopen a profile upload that finished after you left the page.'
			));
			var me = mw.config.get('wgUserName') || '';
			var $filters = $('<div>', { 'class': 'ocr-queue-filters' });
			var $filterMine = $('<label>').append(
				$('<input>', { type: 'checkbox', checked: false, 'class': 'ocr-queue-mine' }),
				' Only mine'
			);
			var $filterReady = $('<label>').append(
				$('<input>', { type: 'checkbox', checked: true, 'class': 'ocr-queue-ready' }),
				' Ready'
			);
			var $filterProc = $('<label>').append(
				$('<input>', { type: 'checkbox', checked: true, 'class': 'ocr-queue-processing' }),
				' In progress'
			);
			$filters.append($filterMine, ' ', $filterReady, ' ', $filterProc);
			var $clearRow = $('<div>', { 'class': 'ocr-queue-clear' });
			var $clearStatus = $('<span>', { 'class': 'ocr-fact-meta' });
			var $clearReady = $('<button>', {
				type: 'button',
				text: 'Clear ready queue',
				title: 'Discard all ready jobs without opening them'
			});
			if (!cfg.canApply) {
				$clearReady.prop('disabled', true);
				$clearStatus.text('Clear queue requires ocr-reviewer or admin.');
			}
			$clearRow.append($clearReady, ' ', $clearStatus);
			var $ul = $('<ul>', { 'class': 'ocr-queue-list' });
			$queue.append($filters, $clearRow, $ul);
			$slot.append($queue);

			function loadQueue() {
				ocrGet({ op: 'queue', status: 'ready' }).then(function (readyResp) {
					return ocrGet({ op: 'queue', status: 'processing' }).then(function (procResp) {
						return {
							ready: (readyResp && readyResp.jobs) || [],
							processing: (procResp && procResp.jobs) || []
						};
					});
				}).then(function (bags) {
					$ul.empty();
					var showMine = $filterMine.find('input').prop('checked');
					var showReady = $filterReady.find('input').prop('checked');
					var showProc = $filterProc.find('input').prop('checked');
					var jobs = [];
					if (showProc) {
						jobs = jobs.concat(bags.processing || []);
					}
					if (showReady) {
						jobs = jobs.concat(bags.ready || []);
					}
					if (showMine) {
						jobs = jobs.filter(function (j) {
							return (j.uploader || '') === me;
						});
					}
					var readyCount = (bags.ready || []).filter(function (j) {
						return !showMine || (j.uploader || '') === me;
					}).length;
					$clearReady.text(
						showMine
							? ('Clear my ready (' + readyCount + ')')
							: ('Clear ready queue (' + readyCount + ')')
					);
					$clearReady.data('readyCount', readyCount);
					$clearReady.data('mineOnly', showMine);
					if (!jobs.length) {
						$ul.append($('<li>').text('No jobs match these filters.'));
						return;
					}
					jobs.forEach(function (job) {
						var $li = $('<li>');
						var $row = $('<div>', { 'class': 'ocr-queue-item' });
						var thumb = publicPreviewUrl(job);
						if (thumb) {
							$row.append($('<img>', {
								'class': 'ocr-queue-thumb',
								src: thumb,
								alt: ''
							}));
						}
						var $meta = $('<div>', { 'class': 'ocr-queue-meta' });
						var ps = job.people_summary || {};
						var peopleBit = isMultiPersonJob(job)
							? ((ps.detected || 0) + ' people · ' + (ps.matched || 0) + ' matched · ' + (ps.unresolved || 0) + ' unmatched')
							: (job.bound_title ? job.bound_title : 'single document');
						var mineBit = (job.uploader || '') === me ? ' · you' : '';
						var kindBit = job.source_kind === 'facebook_seed'
							? ' · FB seed' + (job.source_ref ? (' #' + job.source_ref) : '')
							: '';
						$meta.append($('<div>').append(
							$('<strong>').text(job.filename || job.id),
							document.createTextNode(
								' · ' + (job.doc_type_label || job.doc_type || 'document') + kindBit
							)
						));
						$meta.append($('<div>', { 'class': 'ocr-fact-meta' }).text(
							peopleBit + ' · ' + (job.status || '?') +
							(job.status === 'processing' ? (' ' + (job.progress || 0) + '%') : '') +
							' · by ' + (job.uploader || '?') + mineBit +
							' · ' + timeAgo(job.created_at)
						));
						if (job.apply_progress && job.apply_progress.length) {
							$meta.append($('<div>', { 'class': 'ocr-fact-meta' }).text(
								'Partial apply: ' + job.apply_progress.length + ' page(s) already saved — Open and Apply to resume'
							));
						}
						var $open = $('<button>', { type: 'button', text: 'Open' });
						$meta.append($open);
						$row.append($meta);
						$li.append($row);
						$open.on('click', function () {
							$host2.empty();
							if (job.status === 'processing' || job.status === 'queued') {
								pollJob($host2, job.id, cfg);
							} else {
								ocrGet({ op: 'job', job_id: job.id }).then(function (resp) {
									renderReview($host2, resp.job || resp, cfg);
								});
							}
							window.scrollTo({ top: $host2.offset().top - 40, behavior: 'smooth' });
						});
						$ul.append($li);
					});
				});
			}
			$filters.find('input').on('change', loadQueue);
			$clearReady.on('click', function () {
				if (!cfg.canApply) {
					return;
				}
				var n = Number($clearReady.data('readyCount') || 0);
				var mineOnly = !!$clearReady.data('mineOnly');
				if (!n) {
					$clearStatus.text('No ready jobs to clear.');
					return;
				}
				var msg = mineOnly
					? ('Discard your ' + n + ' ready OCR job(s)? This cannot be undone.')
					: ('Discard all ' + n + ' ready OCR job(s)? This cannot be undone.');
				if (!window.confirm(msg)) {
					return;
				}
				$clearReady.prop('disabled', true);
				$clearStatus.text('Clearing…');
				ocrPost({
					op: 'clearqueue',
					payload: JSON.stringify({
						statuses: 'ready',
						mine_only: mineOnly,
						limit: 500
					})
				}).then(function (resp) {
					var cleared = (resp && resp.cleared && resp.cleared.discarded) || 0;
					$clearStatus.text('Discarded ' + cleared + ' job(s).');
					$host2.empty();
					loadQueue();
				}).catch(function (err) {
					$clearStatus.text(String(err && err.error && err.error.info || err));
				}).always(function () {
					$clearReady.prop('disabled', false);
				});
			});
			loadQueue();
		}
	}).catch(function () {
		/* OCR service optional while wiki is up */
	});
});

(function rbLiveTeammates() {
	var $rails = $('.mw-parser-output .soldier-related[data-current-title]');
	if (!$rails.length || !mw.Api) {
		return;
	}

	function titleToCaption(title) {
		var m = String(title || '').match(/^([^,]+),\s*(.+?)\s*\(/);
		if (m) {
			return $.trim(m[2] + ' ' + m[1]);
		}
		return String(title || '');
	}

	function wikiLink(title, caption) {
		return $('<a></a>')
			.attr('href', mw.util.getUrl(title))
			.attr('title', title)
			.text(caption || titleToCaption(title));
	}

	function fetchCategoryMembers(catTitle) {
		var titles = [];
		function next(cont) {
			var params = {
				action: 'query',
				list: 'categorymembers',
				cmtitle: catTitle,
				cmlimit: 'max',
				cmnamespace: 0,
				cmtype: 'page'
			};
			if (cont) {
				params.cmcontinue = cont;
			}
			return new mw.Api().get(params).then(function (data) {
				var members = (data.query && data.query.categorymembers) || [];
				members.forEach(function (m) {
					if (m.title && !rbIsMergedDuplicateTitle(m.title)) {
						titles.push(m.title);
					}
				});
				var more = data.continue && data.continue.cmcontinue;
				if (more) {
					return next(more);
				}
				return titles;
			});
		}
		return next(null);
	}

	$rails.each(function () {
		var $rail = $(this);
		if ($rail.data('rbLiveTeammates')) {
			return;
		}
		$rail.data('rbLiveTeammates', 1);
		var current = String($rail.attr('data-current-title') || mw.config.get('wgPageName') || '')
			.replace(/_/g, ' ');

		$rail.find('.soldier-related-block[data-team-cat]').each(function () {
			var $block = $(this);
			var cat = String($block.attr('data-team-cat') || '');
			if (!/^Category:Team /.test(cat)) {
				return;
			}
			var $links = $block.find('.soldier-related-links-live').first();
			if (!$links.length) {
				return;
			}
			$links.text('Loading teammates…');
			fetchCategoryMembers(cat).done(function (titles) {
				var others = titles.filter(function (t) {
					return t.replace(/_/g, ' ') !== current;
				});
				others.sort(function (a, b) {
					return titleToCaption(a).localeCompare(titleToCaption(b));
				});
				$links.empty();
				if (!others.length) {
					$links.append(document.createTextNode('No other teammates listed yet · '));
					$links.append(wikiLink(cat, 'full roster'));
					return;
				}
				var max = 24;
				others.slice(0, max).forEach(function (title, i) {
					if (i > 0) {
						$links.append(document.createTextNode(' · '));
					}
					$links.append(wikiLink(title, titleToCaption(title)));
				});
				$links.append(document.createTextNode(' · '));
				$links.append(wikiLink(cat, others.length > max ? 'full roster (' + others.length + ')' : 'full roster'));
			}).fail(function () {
				$links.empty().append(wikiLink(cat, 'Browse team roster'));
			});
		});
	});
}());

$(function rbCampManualUpload() {
	var $slot = $('.mw-parser-output .camp-manual-upload-slot').first();
	if (!$slot.length || $slot.data('rbCampManualUpload')) {
		return;
	}
	$slot.data('rbCampManualUpload', 1);

	var LIBRARY = 'Training materials/Library';
	var PLACEHOLDER = "''No manuals listed yet. Upload a PDF above and it will appear here.''";
	var DOC_EXT = /\.(pdf|docx?|txt|rtf|odt|tiff?|jpe?g|png|gif|webp)$/i;
	var pageTitle = mw.config.get('wgPageName');
	var loggedIn = !!mw.config.get('wgUserName');
	var uploadUrl = mw.util.getUrl('Special:Upload');

	$slot.empty();
	var $box = $('<div>', { 'class': 'camp-manual-upload-box soldier-upload-body' });
	$box.append($('<p>', { 'class': 'soldier-contrib-help' }).append(
		document.createTextNode('Upload a manual or related Camp Ritchie document (PDF preferred). Also '),
		$('<a>', { href: uploadUrl, text: 'Special:Upload' }),
		document.createTextNode('.')
	));

	if (!loggedIn) {
		$box.append($('<p>', { 'class': 'soldier-upload-login' }).html(
			'<a href="' + mw.util.getUrl('Special:UserLogin', { returnto: pageTitle }) + '">Log in</a> to upload files.'
		));
		$slot.append($box);
		return;
	}

	var $form = $('<form>', { 'class': 'soldier-upload-form camp-manual-upload-form' });
	var $file = $('<input>', {
		type: 'file',
		name: 'campManualFile',
		'class': 'soldier-upload-file',
		accept: '.pdf,.doc,.docx,.txt,.rtf,.odt,.tif,.tiff,.jpg,.jpeg,.png'
	});
	var $label = $('<label>').append(
		document.createTextNode('Display title (optional)'),
		$('<input>', {
			type: 'text',
			name: 'campManualTitle',
			'class': 'camp-manual-title',
			placeholder: 'e.g. Interrogation manual, 1943',
			maxlength: 200
		})
	);
	var $note = $('<label>').append(
		document.createTextNode('Short note / source (optional)'),
		$('<input>', {
			type: 'text',
			name: 'campManualNote',
			'class': 'camp-manual-note',
			placeholder: 'e.g. NARA / museum scan',
			maxlength: 300
		})
	);
	var $status = $('<p>', { 'class': 'soldier-upload-status', text: '' });
	var $progress = $('<div>', { 'class': 'camp-manual-progress', hidden: true });
	var $progressFill = $('<div>', { 'class': 'camp-manual-progress-fill' });
	var $progressBar = $('<div>', {
		'class': 'camp-manual-progress-bar',
		role: 'progressbar',
		'aria-valuemin': 0,
		'aria-valuemax': 100,
		'aria-valuenow': 0
	}).append($progressFill);
	var $progressLabel = $('<p>', { 'class': 'camp-manual-progress-label', text: '' });
	$progress.append($progressBar, $progressLabel);
	var $submit = $('<button>', { type: 'submit', text: 'Upload to library' });
	$form.append($file, $label, $note, $submit, $progress, $status);
	$box.append($form);
	$slot.append($box);

	function setStatus(msg, isError) {
		$status.text(msg).css('color', isError ? '#ba0000' : '#202122');
	}

	function formatBytes(n) {
		if (n >= 1024 * 1024) {
			return (n / (1024 * 1024)).toFixed(1) + ' MB';
		}
		if (n >= 1024) {
			return Math.round(n / 1024) + ' KB';
		}
		return n + ' B';
	}

	function setProgress(loaded, total) {
		$progress.prop('hidden', false);
		if (!total) {
			$progress.addClass('is-indeterminate');
			$progressBar.attr('aria-valuenow', 0);
			$progressFill.css('width', '30%');
			$progressLabel.text('Uploading…');
			return;
		}
		$progress.removeClass('is-indeterminate');
		var pct = Math.max(0, Math.min(100, Math.round((loaded / total) * 100)));
		$progressBar.attr('aria-valuenow', pct);
		$progressFill.css('width', pct + '%');
		$progressLabel.text(pct + '% · ' + formatBytes(loaded) + ' of ' + formatBytes(total));
	}

	function resetProgress() {
		$progress.prop('hidden', true).removeClass('is-indeterminate');
		$progressFill.css('width', '0%');
		$progressBar.attr('aria-valuenow', 0);
		$progressLabel.text('');
	}

	function escapePipe(s) {
		return String(s || '').replace(/\|/g, '–').replace(/\[\[/g, '').replace(/\]\]/g, '');
	}

	function buildBullet(filename, displayTitle, note) {
		var label = escapePipe(displayTitle || filename.replace(/_/g, ' '));
		var line = '* [[Media:' + filename + '|' + label + ']]';
		if (note) {
			line += ' — ' + escapePipe(note);
		}
		return line;
	}

	function appendToLibrary(api, filename, displayTitle, note) {
		var bullet = buildBullet(filename, displayTitle, note);
		return api.get({
			action: 'query',
			prop: 'revisions|info',
			titles: LIBRARY,
			rvprop: 'content|timestamp',
			rvslots: 'main',
			curtimestamp: 1,
			formatversion: 2,
			maxage: 0,
			smaxage: 0
		}).then(function (data) {
			var page = data.query && data.query.pages && data.query.pages[0];
			var rev = page && page.revisions && page.revisions[0];
			var text = (rev && rev.slots && rev.slots.main && rev.slots.main.content) || '';
			var basets = rev && rev.timestamp;
			var startts = data.curtimestamp;
			var next;
			var trimmed = $.trim(text);
			var already = trimmed.indexOf('[[Media:' + filename + '|') !== -1 ||
				trimmed.indexOf('[[File:' + filename + '|') !== -1;
			if (already) {
				return purgeLibraryPages(api).then(function () {
					return { edit: { result: 'Success', nochange: true } };
				});
			}
			if (!trimmed || trimmed.indexOf(PLACEHOLDER) !== -1) {
				next = bullet + '\n';
			} else {
				next = trimmed + '\n' + bullet + '\n';
			}
			var params = {
				action: 'edit',
				title: LIBRARY,
				text: next,
				summary: 'Add training material: ' + filename,
				format: 'json'
			};
			if (basets) {
				params.basetimestamp = basets;
			}
			if (startts) {
				params.starttimestamp = startts;
			}
			return api.postWithToken('csrf', params).then(function (editData) {
				if (editData.edit && editData.edit.result === 'Success') {
					return purgeLibraryPages(api).then(function () {
						return editData;
					});
				}
				if (editData.error && editData.error.code === 'editconflict') {
					return appendToLibrary(api, filename, displayTitle, note);
				}
				throw new Error((editData.error && editData.error.info) || 'Could not update library list');
			});
		});
	}

	function failMessage(code, data) {
		if (data && data.error && data.error.info) {
			return data.error.info;
		}
		if (data && data.exception && data.exception.message) {
			return data.exception.message;
		}
		if (code && typeof code === 'object' && code.message) {
			return code.message;
		}
		if (typeof code === 'string' && code && code !== 'http' && code !== 'error' && code !== 'timeout') {
			return code;
		}
		if (data && data.xhr && data.xhr.status) {
			return 'Upload failed (HTTP ' + data.xhr.status + '). Try again, or use Special:Upload.';
		}
		return 'Upload failed (connection dropped or timed out). Try again, or use Special:Upload.';
	}

	function purgeLibraryPages(api) {
		return api.postWithToken('csrf', {
			action: 'purge',
			titles: 'Training materials|' + LIBRARY,
			forcelinkupdate: 1,
			format: 'json'
		});
	}

	function readLibraryText(api) {
		return api.get({
			action: 'query',
			prop: 'revisions|info',
			titles: LIBRARY,
			rvprop: 'content|timestamp',
			rvslots: 'main',
			curtimestamp: 1,
			formatversion: 2,
			maxage: 0,
			smaxage: 0
		}).then(function (data) {
			var page = data.query && data.query.pages && data.query.pages[0];
			var rev = page && page.revisions && page.revisions[0];
			return {
				text: (rev && rev.slots && rev.slots.main && rev.slots.main.content) || '',
				basetimestamp: rev && rev.timestamp,
				starttimestamp: data.curtimestamp
			};
		});
	}

	function writeLibraryText(api, next, summary, basets, startts) {
		var params = {
			action: 'edit',
			title: LIBRARY,
			text: next,
			summary: summary,
			format: 'json'
		};
		if (basets) {
			params.basetimestamp = basets;
		}
		if (startts) {
			params.starttimestamp = startts;
		}
		return api.postWithToken('csrf', params).then(function (editData) {
			if (editData.edit && editData.edit.result === 'Success') {
				return purgeLibraryPages(api).then(function () {
					return editData;
				});
			}
			if (editData.error && editData.error.code === 'editconflict') {
				throw new Error('editconflict');
			}
			throw new Error((editData.error && editData.error.info) || 'Could not update library list');
		});
	}

	function removeFromLibrary(api, filename) {
		return readLibraryText(api).then(function (cur) {
			var lines = String(cur.text || '').split(/\r?\n/);
			var nextLines = lines.filter(function (line) {
				return line.indexOf('[[Media:' + filename + '|') === -1 &&
					line.indexOf('[[File:' + filename + '|') === -1 &&
					line.indexOf(filename) === -1;
			});
			var next = $.trim(nextLines.join('\n'));
			if (!next) {
				next = PLACEHOLDER;
			} else {
				next = next + '\n';
			}
			return writeLibraryText(
				api,
				next,
				'Remove from Training materials library: ' + filename,
				cur.basetimestamp,
				cur.starttimestamp
			).then(null, function (err) {
				if (String(err && err.message || err) === 'editconflict') {
					return removeFromLibrary(api, filename);
				}
				throw err;
			});
		});
	}

	function deleteLibraryFile(api, filename) {
		return api.postWithToken('csrf', {
			action: 'delete',
			title: 'File:' + filename,
			reason: 'Removed from Training materials library',
			format: 'json'
		}).then(function (data) {
			if (data.error) {
				throw new Error(data.error.info || 'Could not delete file');
			}
			return removeFromLibrary(api, filename);
		});
	}

	function filenameFromHref(href, titleAttr) {
		if (titleAttr && /\.[a-z0-9]{2,5}$/i.test(titleAttr)) {
			return String(titleAttr).replace(/ /g, '_');
		}
		if (!href) {
			return '';
		}
		try {
			href = decodeURIComponent(href);
		} catch (e) { /* ignore */ }
		var m = href.match(/\/wiki\/(?:File|Media):([^?#]+)/i) ||
			href.match(/[?&]title=(?:File|Media):([^&#]+)/i) ||
			href.match(/\/images\/(?:thumb\/)?[^/]+\/[^/]+\/([^/?#]+)$/i);
		if (!m) {
			return '';
		}
		return m[1].replace(/_/g, ' ').trim().replace(/ /g, '_');
	}

	function enhanceLibraryManage() {
		if (!mw.config.get('wgUserName')) {
			return;
		}
		var $heading = $('.mw-parser-output .mw-heading, .mw-parser-output h2').filter(function () {
			return /^Library\b/i.test($(this).text().replace(/\s*\[.*$/, '').trim());
		}).first();
		if (!$heading.length) {
			return;
		}
		var $list = $heading.nextAll('ul').first();
		if (!$list.length) {
			$list = $heading.parent().nextAll('ul').first();
		}
		if (!$list.length || $list.data('rbCampManualManage')) {
			return;
		}
		$list.data('rbCampManualManage', 1);

		var groups = mw.config.get('wgUserGroups') || [];
		var canDeleteFile = groups.indexOf('sysop') !== -1;
		var api = new mw.Api();

		$list.children('li').each(function () {
			var $li = $(this);
			if ($li.find('.camp-manual-lib-actions').length) {
				return;
			}
			var $a = $li.find('a.internal, a[href*="/images/"], a[href*="File:"], a[href*="Media:"]').first();
			if (!$a.length) {
				$a = $li.find('a[href]').first();
			}
			var filename = filenameFromHref($a.attr('href'), $a.attr('title'));
			if (!filename) {
				return;
			}
			var $actions = $('<span>', { 'class': 'camp-manual-lib-actions' });
			var $remove = $('<button>', {
				type: 'button',
				'class': 'camp-manual-lib-remove',
				text: 'Remove'
			}).attr('title', 'Remove from this list (file stays on the wiki)');
			$actions.append($remove);
			if (canDeleteFile) {
				$actions.append($('<button>', {
					type: 'button',
					'class': 'camp-manual-lib-delete',
					text: 'Delete file'
				}).attr('title', 'Delete the file from the wiki (administrators only)'));
			}
			$li.append($actions);

			$remove.on('click', function () {
				if (!window.confirm('Remove “' + filename.replace(/_/g, ' ') + '” from the Library list?\n(The file will remain on the wiki.)')) {
					return;
				}
				$actions.find('button').prop('disabled', true);
				removeFromLibrary(api, filename).done(function () {
					window.location.reload();
				}).fail(function (code, data) {
					window.alert(failMessage(code, data));
					$actions.find('button').prop('disabled', false);
				});
			});

			$actions.on('click', '.camp-manual-lib-delete', function () {
				if (!window.confirm('Permanently delete file “' + filename.replace(/_/g, ' ') + '” from the wiki?')) {
					return;
				}
				$actions.find('button').prop('disabled', true);
				deleteLibraryFile(api, filename).done(function () {
					window.location.reload();
				}).fail(function (code, data) {
					window.alert(failMessage(code, data));
					$actions.find('button').prop('disabled', false);
				});
			});
		});
	}

	enhanceLibraryManage();

	$form.on('submit', function (evt) {
		evt.preventDefault();
		var file = $file[0].files[0];
		if (!file) {
			setStatus('Choose a file first.', true);
			return;
		}
		if (!DOC_EXT.test(file.name)) {
			setStatus('File type not allowed (PDF, Office docs, images, TIFF).', true);
			return;
		}
		var maxBytes = 120 * 1024 * 1024;
		if (file.size > maxBytes) {
			setStatus('File is too large (max about 120 MB). Split the PDF or compress it, then try again.', true);
			return;
		}
		var displayTitle = $.trim($form.find('.camp-manual-title').val() || '');
		var note = $.trim($form.find('.camp-manual-note').val() || '');
		$submit.prop('disabled', true);
		resetProgress();
		setProgress(0, file.size);
		setStatus('Uploading…');

		var api = new mw.Api({ ajax: { timeout: 0 } });
		var fileText = 'Camp Ritchie / MITC training material.';
		var uploadedName = null;
		var uploadParams = {
			filename: file.name,
			comment: 'Uploaded to Training materials library',
			text: fileText,
			ignorewarnings: true
		};

		function doUpload() {
			/* Multipart + XHR progress (api.upload alone often has no progress events). */
			return api.postWithToken('csrf', $.extend({
				action: 'upload',
				file: file,
				format: 'json'
			}, uploadParams), {
				contentType: 'multipart/form-data',
				timeout: 0,
				xhr: function () {
					var xhr = $.ajaxSettings.xhr();
					if (xhr.upload) {
						xhr.upload.addEventListener('progress', function (ev) {
							if (ev.lengthComputable) {
								setProgress(ev.loaded, ev.total);
							}
						});
					}
					return xhr;
				}
			});
		}

		doUpload().then(function (data) {
			uploadedName = data.upload && data.upload.filename;
			if (!uploadedName) {
				throw new Error((data.error && data.error.info) || 'Upload failed');
			}
			setProgress(file.size, file.size);
			$progress.addClass('is-indeterminate');
			$progressLabel.text('Finishing…');
			setStatus('Adding to Library list…');
			return appendToLibrary(api, uploadedName, displayTitle, note);
		}).done(function () {
			$progress.removeClass('is-indeterminate');
			$progressLabel.text('100% · done');
			setStatus('Done. Reloading…');
			window.location.reload();
		}).fail(function (code, data) {
			var err = failMessage(code, data);
			if (uploadedName) {
				err = 'File uploaded as “' + uploadedName + '”, but the Library list was not updated: ' + err +
					' Refresh this page; if it is missing from the list, say so and we can add it.';
			}
			setStatus(String(err), true);
			resetProgress();
			$submit.prop('disabled', false);
		});
	});
});

(function rbBooksFilters() {
	var $host = $('.books-filters-host').first();
	var $slides = $('.mw-parser-output .notable-slides').first();
	if (!$host.length || !$slides.length) return;
	if (!$slides.find('.notable-slide-card[class*="book-kind-"]').length) return;
	if ($host.find('.books-filters').length) return;

	var filters = [
		{ id: 'all', label: 'All' },
		{ id: 'memoir', label: 'Memoirs & letters' },
		{ id: 'history', label: 'History' },
		{ id: 'novel', label: 'Novels' },
		{ id: 'other', label: 'Other' }
	];
	var $bar = $('<div class="books-filters" role="group" aria-label="Filter books"></div>');
	filters.forEach(function (f, i) {
		var $btn = $('<button type="button" class="books-filter"></button>');
		$btn.attr('data-filter', f.id).text(f.label);
		if (i === 0) $btn.addClass('is-active');
		$bar.append($btn);
	});
	$host.empty().append($bar);

	$bar.on('click', '.books-filter', function () {
		var kind = $(this).attr('data-filter');
		$bar.find('.books-filter').removeClass('is-active');
		$(this).addClass('is-active');
		$slides.find('.notable-slide-card').each(function () {
			var $c = $(this);
			var cls = $c.attr('class') || '';
			var m = cls.match(/book-kind-([a-z]+)/);
			var k = m ? m[1] : 'other';
			var show = kind === 'all' || k === kind;
			$c.toggleClass('is-book-hidden', !show);
		});
	});
}());

/* Related-media wrap removed: story/interview cues already sit under the lead
 * with their own labels (Full story, etc.). Wrapping them added a stray
 * Related media heading near the top of the article. */