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
OCR review UX: person cards, apply preview, Advanced
Add Internet Archive click-to-play embeds for interview watchlist
 
(36 intermediate revisions by the same user not shown)
Line 7: Line 7:
  * with live title suggestions (same idea as the header search box).
  * 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.
  * 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 () {
(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 111: Line 153:
function rankTitles(query, hits) {
function rankTitles(query, hits) {
var q = String(query || '').trim().toLowerCase();
var q = String(query || '').trim().toLowerCase();
var scored = (hits || []).map(function (h) {
var scored = (hits || []).filter(function (h) {
return !rbIsMergedDuplicateTitle(h.title || '');
}).map(function (h) {
var t = h.title || '';
var t = h.title || '';
var low = t.toLowerCase();
var low = t.toLowerCase();
Line 284: Line 328:
function resort(siteTitles) {
function resort(siteTitles) {
var items = $list.children('li').get();
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) {
if (items.length < 2) {
return;
return;
Line 327: Line 377:
resort(siteTitles);
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);
}
}());
}());


Line 384: Line 479:
}());
}());


(function placeSoldierAwardsSection() {
(function buildYouTubeWatchlist() {
// Ribbon racks belong in the main column after Biography (like Wikipedia's
var YT_ID = /^[A-Za-z0-9_-]{11}$/;
// Awards section). Do not use a wiki == heading here — that stole the TOC.
 
var $out = $('.mw-parser-output').first();
function splitSoldiers(raw) {
var $awards = $out.find('.soldier-awards-section').first();
return String(raw || '').split(';').map(function (s) {
if (!$out.length) {
return $.trim(s);
return;
}).filter(Boolean);
}
}


function sectionAfter($h) {
function overlap(a, b) {
var $nodes = $h.nextUntil('h2');
var i, j;
if ($nodes.length) {
for (i = 0; i < a.length; i++) {
return $nodes.last();
for (j = 0; j < b.length; j++) {
}
if (a[i] === b[j]) {
return $h;
return true;
}
}
 
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);
}
}
}
}
return false;
}
}


// Class/team rail rides with awards (after Biography).
function playEmbed($slot, id, title) {
var $related = $out.find('.soldier-related').first();
var src = 'https://www.youtube-nocookie.com/embed/' +
if ($related.length && !$related.data('rbRelatedPlaced')) {
encodeURIComponent(id) + '?autoplay=1&rel=0';
$related.data('rbRelatedPlaced', 1);
var $iframe = $('<iframe class="yt-embed-frame" allowfullscreen allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"></iframe>')
var $awardsNow = $out.find('.soldier-awards-section').first();
.attr({
if ($awardsNow.length) {
src: src,
$awardsNow.after($related);
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 {
} 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));
$poster.on('click', function (ev) {
}).first();
ev.preventDefault();
if ($bio2.length) {
playEmbed($slot, id, title);
sectionAfter($bio2).after($related);
});
}
});
 
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;
}
}


// Story cue belongs under the lead (above Biography).
var $cards = $('.mw-parser-output .yt-interview').filter(function () {
var $storyCue = $out.find('.soldier-story-cue').first();
return interviewCardRef($(this)) !== null;
if ($storyCue.length && !$storyCue.data('rbStoryCuePlaced')) {
});
$storyCue.data('rbStoryCuePlaced', 1);
$cards.each(function (idx) {
var $leadCue = $out.find('.soldier-lead, .page-lead').first();
var $card = $(this);
if ($leadCue.length) {
if ($card.data('rbYtNext')) {
$leadCue.after($storyCue);
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 = {};


// Contents box belongs after the lead on soldier/profile pages only.
function addCard($other, why) {
// Other pages (e.g. Site changelog) keep MediaWiki's default TOC placement.
var ref = interviewCardRef($other);
var $toc = $out.find('#toc, .toc, .mw-table-of-contents').first();
if (!ref || !selfRef || seen[ref.key] || ref.key === selfRef.key) {
if ($toc.length && !$toc.data('rbTocPlaced')) {
return;
$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);
}
}
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
});
}
}
}
}());


(function moveSoldierFooterCues() {
$cards.each(function (j) {
var $out = $('.mw-parser-output').first();
if (j === idx || picks.length >= 3) {
if (!$out.length) {
return;
return;
}
}
var $other = $($cards.get(j));
var $notes = $out.find('.research-notes-disclaimer').first();
if (overlap(mine, splitSoldiers($other.attr('data-soldiers')))) {
var $sources = $out.find('.source-confidence-cue').first();
addCard($other, 'same soldier');
if (!$notes.length && !$sources.length) {
}
return;
});
}
if (idx + 1 < $cards.length) {
var $footer = $out.find('.soldier-page-footer-notes').first();
addCard($($cards.get(idx + 1)), 'next');
if (!$footer.length) {
}
$footer = $('<div class="soldier-page-footer-notes"></div>');
if (!picks.length) {
$out.append($footer);
return;
}
}
if ($notes.length) {
var $list = $('<ul class="yt-interview-next-list"></ul>');
$footer.append($notes);
picks.slice(0, 3).forEach(function (item) {
}
var label = item.short ? (item.heading + ' — ' + item.short) : item.heading;
if ($sources.length) {
var $a = $('<a></a>').attr('href', item.href).text(label);
$footer.append($sources);
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);
});
}());
}());


var NAME_PARTICLES = {
(function buildSoundCloudEmbeds() {
von: 1, van: 1, de: 1, der: 1, den: 1, da: 1, di: 1, du: 1,
var SC_TRACK = /^[0-9]{6,12}$/;
la: 1, le: 1, del: 1, della: 1, ter: 1, ten: 1, zu: 1, zum: 1, bin: 1
 
};
function playSoundCloud($slot, trackId, title) {
var NAME_SUFFIX = {
var src = 'https://w.soundcloud.com/player/?url=' +
jr: 'Jr', 'jr.': 'Jr.', sr: 'Sr', 'sr.': 'Sr.',
encodeURIComponent('https://api.soundcloud.com/tracks/' + trackId) +
esq: 'Esq', 'esq.': 'Esq.', ii: 'II', iii: 'III', iv: 'IV'
'&color=%23ff5500&auto_play=true&hide_related=true&show_comments=false' +
};
'&show_user=true&show_reposts=false&show_teaser=false&visual=true';
function lettersOnly(s) {
var $iframe = $('<iframe class="sc-embed-frame" scrolling="no" frameborder="no" allow="autoplay"></iframe>')
return String(s || '').replace(/[^A-Za-z]/g, '');
.attr({
}
src: src,
function isAllCaps(s) {
title: title || 'SoundCloud audio',
var letters = lettersOnly(s);
loading: 'lazy'
return letters.length >= 1 && letters === letters.toUpperCase();
});
}
$slot.empty().append($iframe).addClass('sc-embed-playing');
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') {
$('.mw-parser-output .sc-embed[data-soundcloud-track]').each(function () {
return 'Mc' + titleCaseWord(up.slice(2)) + trail;
var $slot = $(this);
}
if ($slot.data('rbScEmbed')) {
if (up.length >= 7 && up.slice(0, 3) === 'MAC') {
return;
return 'Mac' + titleCaseWord(up.slice(3)) + trail;
}
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');
}
}
return titleCaseWord(up) + trail;
 
}
$('.mw-parser-output .aviary-embed[data-aviary-media]').each(function () {
function prettyNameToken(word, leading) {
var $slot = $(this);
word = $.trim(word);
if ($slot.data('rbAviaryEmbed')) {
if (!word) {
return;
return word;
}
}
var mediaId = String($slot.attr('data-aviary-media') || '');
if (/\d/.test(word)) {
if (!AV_MEDIA.test(mediaId)) {
return word;
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');
}
}
if (word.indexOf('-') !== -1 && word.charAt(0) !== '-' && word.slice(-1) !== '-') {
 
return $.map(word.split('-'), function (part, i) {
$('.mw-parser-output .ia-embed[data-archive-id]').each(function () {
return prettyNameToken(part, leading && i === 0);
var $slot = $(this);
}).join('-');
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;
}
}
var apos = word.indexOf("'") !== -1 ? "'" : (word.indexOf('\u2019') !== -1 ? '\u2019' : '');
 
if (apos) {
function sectionAfter($h) {
var bits = word.split(apos);
var $nodes = $h.nextUntil('h2');
return prettyNameToken(bits[0], leading) + apos + prettyNameToken(bits.slice(1).join(apos), false);
if ($nodes.length) {
}
return $nodes.last();
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;
return $h;
}
}
if (NAME_PARTICLES[lowBare] && /^[A-Za-z]+$/.test(word) && (isAllCaps(word) || isAllLower(word))) {
 
return leading ? titleCaseWord(lowBare) : lowBare;
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);
}
}
}
}
if (isAllCaps(word) && bare.length >= 2 && bare.length <= 3 && !/[AEIOUY]/i.test(bare)) {
 
return word;
// Class/team rail rides with awards (after Biography).
}
var $related = $out.find('.soldier-related').first();
if (isAllCaps(word) || isAllLower(word)) {
if ($related.length && !$related.data('rbRelatedPlaced')) {
return prettyCapsWord(word);
$related.data('rbRelatedPlaced', 1);
}
var $awardsNow = $out.find('.soldier-awards-section').first();
return word;
if ($awardsNow.length) {
}
$awardsNow.after($related);
function prettyNamePhrase(text, capLead) {
} else {
text = $.trim(text || '').replace(/\s*;\s*/g, ' ');
var $bio2 = $out.find('h2').filter(function () {
if (!text) {
var t = $(this).find('.mw-headline').text() || $(this).text();
return '';
return /^(Biography|Life)\b/i.test($.trim(t));
}).first();
if ($bio2.length) {
sectionAfter($bio2).after($related);
}
}
}
}
return $.map(text.split(/\s*,\s*/), function (clause, clauseI) {
 
clause = $.trim(clause);
// Story cue belongs under the lead (above Biography).
if (!clause) {
var $storyCue = $out.find('.soldier-story-cue').first();
return null;
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);
}
}
var words = clause.split(/\s+/);
}
return $.map(words, function (word, i) {
 
var particle = lettersOnly(word).toLowerCase();
// Interview / film cue sits with the story cue under the lead.
var leading = (clauseI === 0 && i === 0 && capLead) || words.length === 1;
var $mediaCue = $out.find('.soldier-media-cue').first();
if (NAME_PARTICLES[particle] && /^[A-Za-z]+$/.test(word) && (isAllCaps(word) || isAllLower(word))) {
if ($mediaCue.length && !$mediaCue.data('rbMediaCuePlaced')) {
return leading ? titleCaseWord(particle) : word.toLowerCase();
$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);
}
}
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] + ')';
 
}
// Contents box belongs after the lead on soldier/profile pages only.
function maybePrettySoldierTitle(label) {
// Other pages (e.g. Site changelog) keep MediaWiki's default TOC placement.
var text = $.trim(label || '');
var $toc = $out.find('#toc, .toc, .mw-table-of-contents').first();
if (!text || text.indexOf(',') === -1) {
if ($toc.length && !$toc.data('rbTocPlaced')) {
return text;
$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);
}
}
}
}
var sur = text.split(',')[0];
}());
var letters = lettersOnly(sur);
 
if (!(letters.length >= 2 && letters === letters.toUpperCase())) {
(function moveSoldierFooterCues() {
return text;
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);
}
}
return prettySoldierLabel(text);
if ($sources.length) {
}
$footer.append($sources);
function applyPrettySoldierLabels($links) {
}
$links.each(function () {
}());
var $a = $(this);
 
var label = $a.text();
var NAME_PARTICLES = {
var pretty = maybePrettySoldierTitle(label);
von: 1, van: 1, de: 1, der: 1, den: 1, da: 1, di: 1, du: 1,
if (pretty && pretty !== label) {
la: 1, le: 1, del: 1, della: 1, ter: 1, ten: 1, zu: 1, zum: 1, bin: 1
$a.text(pretty);
};
if ($a.attr('title') === label || !$a.attr('title')) {
var NAME_SUFFIX = {
$a.attr('title', pretty);
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();
}
}
if (mw.config.get('wgNamespaceNumber') === 14) {
function isAllLower(s) {
applyPrettySoldierLabels($('.mw-category-group li a'));
var letters = lettersOnly(s);
return letters.length >= 2 && letters === letters.toLowerCase();
}
}
if (mw.config.get('wgCanonicalSpecialPageName') === 'Search') {
function titleCaseWord(s) {
applyPrettySoldierLabels($('.mw-search-result-heading a'));
s = String(s || '').toLowerCase();
return s ? s.charAt(0).toUpperCase() + s.slice(1) : s;
}
}
// Search box suggestions use the real page title; pretty-case ALL-CAPS surnames as they appear.
function prettyCapsWord(word) {
(function watchSearchSuggestions() {
var trail = '';
function polish() {
while (word && /[.,]$/.test(word)) {
applyPrettySoldierLabels(
trail = word.slice(-1) + trail;
$('.suggestions-result a, .suggestions-special a, .cdx-menu-item__text__label, .mw-searchSuggest-link')
word = word.slice(0, -1);
);
}
$('.suggestions-result, .cdx-menu-item__text__label').each(function () {
var up = lettersOnly(word).toUpperCase();
var $el = $(this);
if (up.length >= 5 && up.slice(0, 2) === 'MC') {
if ($el.is('a')) {
return 'Mc' + titleCaseWord(up.slice(2)) + trail;
return;
}
var label = $el.text();
var pretty = maybePrettySoldierTitle(label);
if (pretty && pretty !== label) {
$el.text(pretty);
}
});
}
}
var root = document.body;
if (up.length >= 7 && up.slice(0, 3) === 'MAC') {
if (!root || !window.MutationObserver) {
return 'Mac' + titleCaseWord(up.slice(3)) + trail;
return;
}
}
var obs = new MutationObserver(function () {
return titleCaseWord(up) + trail;
polish();
}
});
function prettyNameToken(word, leading) {
obs.observe(root, { childList: true, subtree: true });
word = $.trim(word);
}());
if (!word) {
 
return word;
$('.infobox th, .soldier-infobox th, table.wikitable td').each(function () {
}
var text = $(this).text().replace(/\s+/g, ' ').trim();
if (/\d/.test(word)) {
if (
return word;
text === 'Overseas' ||
text === 'Over Seas Flag' ||
text === 'Special'
) {
$(this).closest('tr').remove();
}
}
});
if (word.indexOf('-') !== -1 && word.charAt(0) !== '-' && word.slice(-1) !== '-') {
 
return $.map(word.split('-'), function (part, i) {
function teamHref(name) {
return prettyNameToken(part, leading && i === 0);
return '/index.php?title=' + encodeURIComponent('Team ' + name).replace(/%20/g, '_');
}).join('-');
}
}
function teamLink(name, label) {
var apos = word.indexOf("'") !== -1 ? "'" : (word.indexOf('\u2019') !== -1 ? '\u2019' : '');
return '<a href="' + teamHref(name) + '" title="Team ' + name + '">' + label + '</a>';
if (apos) {
}
var bits = word.split(apos);
function linkifyTeams(raw) {
return prettyNameToken(bits[0], leading) + apos + prettyNameToken(bits.slice(1).join(apos), false);
var text = $('<div>').text(raw).html();
}
text = text.replace(/\bMII[\s-–]+(540|545|546)\s*[-–]\s*R\b/gi, function (_, num) {
if (/^(?:[A-Za-z]\.)+$/.test(word)) {
return teamLink('MII ' + num, 'MII ' + num);
return (isAllCaps(word) || isAllLower(word)) ? word.toUpperCase() : word;
});
}
text = text.replace(/(?<!\bMII[\s-–])\b(540|545|546)\s*[-–]\s*R\b/g, function (_, num) {
var low = word.toLowerCase();
return teamLink('MII ' + num, num);
if (NAME_SUFFIX[low]) {
});
return NAME_SUFFIX[low];
text = text.replace(/\bMII[\s-–]+(\d{2,4})\s*[-–]\s*([A-Za-z])\b/gi, function (_, num, letter) {
}
var name = 'MII ' + num + '-' + letter.toUpperCase();
var bare = lettersOnly(word);
return teamLink(name, 'MII ' + num + '-' + letter.toUpperCase());
if (bare.length === 1) {
});
return bare.toUpperCase() + word.replace(/[A-Za-z]/, '');
text = text.replace(/\bMII[\s-]+(\d{2,4})\b/gi, function (_, num) {
}
return teamLink('MII ' + num, 'MII ' + num);
var lowBare = bare.toLowerCase();
});
if (NAME_SUFFIX[lowBare] && /^[A-Za-z]+\.?$/.test(word)) {
text = text.replace(/(?<!\bMII[\s-–])\b([4-5]\d{2})\s*[-–]\s*([A-Za-z])\b/g, function (_, num, letter) {
var canon = NAME_SUFFIX[lowBare];
var name = 'MII ' + num + '-' + letter.toUpperCase();
if (word.slice(-1) === '.' && canon.slice(-1) !== '.') {
return teamLink(name, num + '-' + letter.toUpperCase());
return canon + '.';
});
}
text = text.replace(/\b(?:IPW\s+)?(?:Italian\s+)?5th\s+class\b/gi, function () {
return canon;
return teamLink('IPW Italian 5', 'IPW Italian 5');
}
});
if (NAME_PARTICLES[lowBare] && /^[A-Za-z]+$/.test(word) && (isAllCaps(word) || isAllLower(word))) {
text = text.replace(/\bIPW\s+(\d{1,4})\s*[-–]\s*([A-Za-z])\b/gi, function (_, num, letter) {
return leading ? titleCaseWord(lowBare) : lowBare;
var name = 'MII ' + num + '-' + letter.toUpperCase();
}
return teamLink(name, 'MII ' + num + '-' + letter.toUpperCase());
if (isAllCaps(word) && bare.length >= 2 && bare.length <= 3 && !/[AEIOUY]/i.test(bare)) {
});
return word;
text = text.replace(/\bIPW\s+(\d{1,4})[Tt]?\s*\?/gi, function (_, num) {
}
return teamLink('IPW ' + num, 'IPW ' + num);
if (isAllCaps(word) || isAllLower(word)) {
});
return prettyCapsWord(word);
text = text.replace(/\bIPW\s+(\d{1,4})[Tt]?\b/gi, function (_, num) {
}
return teamLink('IPW ' + num, 'IPW ' + num);
return word;
});
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 prettyNamePhrase(text, capLead) {
function normalizeNoteKey(text) {
text = $.trim(text || '').replace(/\s*;\s*/g, ' ');
text = $.trim(text || '').toLowerCase();
if (!text) {
text = text.replace(/^also\s+/, '');
return '';
text = text.replace(/[^\w\s\-/]/g, ' ');
}
return text.replace(/\s+/g, ' ').trim();
return $.map(text.split(/\s*,\s*/), function (clause, clauseI) {
}
clause = $.trim(clause);
 
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) {
if (!clause) {
return;
return null;
}
}
var date = dateOnlyFragment(clause);
var words = clause.split(/\s+/);
if (date && out.length) {
return $.map(words, function (word, i) {
var prev = out[out.length - 1].replace(/[ ,;]+$/, '');
var particle = lettersOnly(word).toLowerCase();
if (prev.slice(-date.length) !== date) {
var leading = (clauseI === 0 && i === 0 && capLead) || words.length === 1;
out[out.length - 1] = prev + ', ' + date;
if (NAME_PARTICLES[particle] && /^[A-Za-z]+$/.test(word) && (isAllCaps(word) || isAllLower(word))) {
return leading ? titleCaseWord(particle) : word.toLowerCase();
}
}
return;
return prettyNameToken(word, i === 0);
}
}).join(' ');
out.push(clause);
}).join(', ');
});
return out;
}
}
 
function prettySoldierLabel(text) {
function splitNotes(text) {
var m = $.trim(text || '').match(/^(.*) \(([^()]+)\)\s*$/);
text = $.trim(text || '');
if (!m || m[1].indexOf('(') !== -1) {
if (!text) {
return prettyNamePhrase(text, true);
return [];
}
}
text = text.replace(/"+/g, '"');
return prettyNamePhrase(m[1], true) + ' (' + m[2] + ')';
var parts = [];
}
$.each(text.split(/[;"]+/), function (_, chunk) {
function maybePrettySoldierTitle(label) {
chunk = $.trim(chunk.replace(/^['"]+|['"]+$/g, ''));
var text = $.trim(label || '');
if (!chunk) {
if (!text || text.indexOf(',') === -1) {
return;
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 (/https?:\/\//i.test(chunk)) {
});
parts.push(chunk);
}
return;
if (mw.config.get('wgNamespaceNumber') === 14) {
}
applyPrettySoldierLabels($('.mw-category-group li a'));
chunk = chunk.replace(/(\d{1,2}\/\d{1,2}\/\d{2,4})(?=[A-Za-z])/g, '$1\n');
}
$.each(chunk.split('\n'), function (__, bit) {
if (mw.config.get('wgCanonicalSpecialPageName') === 'Search') {
bit = $.trim(bit.replace(/^['"]+|['"]+$/g, ''));
applyPrettySoldierLabels($('.mw-search-result-heading a'));
if (!bit) {
}
// 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;
return;
}
}
var dated = bit.match(/^(.*?)\s+(\d{1,2}\/\d{1,2}\/\d{2,4})$/);
var label = $el.text();
if (dated && $.trim(dated[1]) && !/^\d{1,2}\/\d{1,2}\/\d{2,4}$/.test($.trim(dated[1]))) {
var pretty = maybePrettySoldierTitle(label);
parts.push($.trim(dated[1]));
if (pretty && pretty !== label) {
parts.push(dated[2]);
$el.text(pretty);
} else {
parts.push(bit);
}
}
});
});
});
}
return joinDateFollowons(parts);
var root = document.body;
}
if (!root || !window.MutationObserver) {
return;
}
var obs = new MutationObserver(function () {
polish();
});
obs.observe(root, { childList: true, subtree: true });
}());


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;
$('.infobox th, .soldier-infobox th, table.wikitable td').each(function () {
var $ul = $heading.nextAll('ul').first();
var text = $(this).text().replace(/\s+/g, ' ').trim();
if (!$ul.length) {
if (
return;
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>';
}
}
var items = [];
function linkifyTeams(raw) {
$ul.find('li').each(function () {
var text = $('<div>').text(raw).html();
$.each(splitNotes($(this).text()), function (_, clause) {
text = text.replace(/\bMII[\s-–]+(540|545|546)\s*[-–]\s*R\b/gi, function (_, num) {
if (clause) {
return teamLink('MII ' + num, 'MII ' + num);
items.push(clause);
});
}
text = text.replace(/(?<!\bMII[\s-–])\b(540|545|546)\s*[-–]\s*R\b/g, function (_, num) {
});
return teamLink('MII ' + num, num);
});
});
items = joinDateFollowons(items);
text = text.replace(/\bMII[\s-–]+(\d{2,4})\s*[-–]\s*([A-Za-z])\b/gi, function (_, num, letter) {
var unique = [];
var name = 'MII ' + num + '-' + letter.toUpperCase();
var seen = {};
return teamLink(name, 'MII ' + num + '-' + letter.toUpperCase());
$.each(items, function (_, clause) {
});
var key = normalizeNoteKey(clause);
text = text.replace(/\bMII[\s-–]+(\d{2,4})\b/gi, function (_, num) {
if (clause && key && !negative.test(clause) && !seen[key]) {
return teamLink('MII ' + num, 'MII ' + num);
seen[key] = true;
});
unique.push(clause);
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());
items = unique;
});
$ul.empty();
text = text.replace(/\b(?:IPW\s+)?(?:Italian\s+)?5th\s+class\b/gi, function () {
$.each(items, function (_, clause) {
return teamLink('IPW Italian 5', 'IPW Italian 5');
$ul.append($('<li>').html(linkifyTeams(clause)));
});
});
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());
/**
});
* Soldier page contribute tools — compact disclosure under Gallery heading.
text = text.replace(/\bIPW\s+(\d{1,4})[Tt]?\s*\?/gi, function (_, num) {
* Sits in the text column beside the floated infobox (no clear:both gap).
return teamLink('IPW ' + num, 'IPW ' + num);
*/
});
$(function () {
text = text.replace(/\bIPW\s+(\d{1,4})[Tt]?\b/gi, function (_, num) {
if (mw.config.get('wgNamespaceNumber') !== 0 || !$('.soldier-infobox').length) {
return teamLink('IPW ' + num, 'IPW ' + num);
return;
});
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;
}
}


var PHOTO_EXT = /\.(jpe?g|png|gif|webp)$/i;
function normalizeNoteKey(text) {
var DOC_EXT = /\.(pdf|docx?|txt|rtf|odt|tiff?)$/i;
text = $.trim(text || '').toLowerCase();
var ALLOWED = /\.(jpe?g|png|gif|webp|pdf|docx?|txt|rtf|odt|tiff?)$/i;
text = text.replace(/^also\s+/, '');
 
text = text.replace(/[^\w\s\-/]/g, ' ');
function headingText($el) {
return text.replace(/\s+/g, ' ').trim();
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) {
function dateOnlyFragment(text) {
var $wrap = $h2.closest('.mw-heading');
var m = $.trim(text || '').match(/^(\d{1,2}\/\d{1,2}\/\d{2,4})\.?$/);
return $wrap.length ? $wrap : $h2;
return m ? m[1] : '';
}
}


function findSectionHeading(names) {
function joinDateFollowons(parts) {
var re = new RegExp('^(' + names.join('|') + ')$', 'i');
var out = [];
var $found = $();
$.each(parts, function (_, clause) {
$('h2').each(function () {
clause = $.trim(clause || '');
if (re.test(headingText($(this)))) {
if (!clause) {
$found = $(this);
return;
return false;
}
}
});
var date = dateOnlyFragment(clause);
return $found;
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 ensureGalleryAnchor() {
function splitNotes(text) {
var $h2 = $('#Gallery');
text = $.trim(text || '');
if (!$h2.length || !$h2.is('h2')) {
if (!text) {
$h2 = findSectionHeading(['Gallery']);
return [];
}
}
if ($h2.length) {
text = text.replace(/"+/g, '"');
return headingWrap($h2);
var parts = [];
}
$.each(text.split(/[;"]+/), function (_, chunk) {
var $anchor = findSectionHeading(['Technical record', 'Sources']);
chunk = $.trim(chunk.replace(/^['"]+|['"]+$/g, ''));
var $wrap = $('<div>', { 'class': 'mw-heading mw-heading2' });
if (!chunk) {
$wrap.append($('<h2>', { id: 'Gallery' }).text('Gallery'));
return;
if ($anchor.length) {
}
headingWrap($anchor).before($wrap);
if (/https?:\/\//i.test(chunk)) {
} else {
parts.push(chunk);
$('.mw-parser-output').first().append($wrap);
return;
}
}
return $wrap;
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);
}
}


function placeContributeBox($box) {
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;
if ($('.soldier-upload-box').length) {
var $ul = $heading.nextAll('ul').first();
return;
if (!$ul.length) {
}
return;
/* Always directly under the Gallery heading — same place on every profile. */
ensureGalleryAnchor().after($box);
}
}
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)));
});
});


var pageTitle = mw.config.get('wgPageName');
/**
var loggedIn = !!mw.config.get('wgUserName');
* Soldier page contribute tools — compact disclosure under Gallery heading.
var uploadUrl = mw.util.getUrl('Special:Upload') + '&wpRitchieSoldierPage=' + encodeURIComponent(pageTitle);
* Sits in the text column beside the floated infobox (no clear:both gap).
 
*/
var $box = $('<details>', { 'class': 'soldier-upload-box' });
$(function () {
var $summary = $('<summary>').text('Add photos or OCR a document');
if (mw.config.get('wgNamespaceNumber') !== 0 || !$('.soldier-infobox').length) {
$box.append($summary);
return;
}


var $body = $('<div>', { 'class': 'soldier-upload-body' });
var PHOTO_EXT = /\.(jpe?g|png|gif|webp)$/i;
$body.append($('<p>', { 'class': 'soldier-contrib-help' }).append(
var DOC_EXT = /\.(pdf|docx?|txt|rtf|odt|tiff?)$/i;
document.createTextNode('Photos for the portrait or Gallery, or a document scan for OCR review. Also '),
var ALLOWED = /\.(jpe?g|png|gif|webp|pdf|docx?|txt|rtf|odt|tiff?)$/i;
$('<a>', { href: uploadUrl, text: 'Special:Upload' }),
document.createTextNode('.')
));


var $photoSection = $('<div>', { 'class': 'soldier-contrib-section soldier-contrib-upload' });
function headingText($el) {
$photoSection.append($('<p>', { 'class': 'soldier-contrib-heading' }).append(
var $h = $el.is('h2') ? $el : $el.find('h2').first();
$('<strong>').text('Photo or document upload')
return $.trim(($h.text() || '').replace(/\s*\[edit.*$/i, '').replace(/\s*edit\s*(source)?\s*/ig, ''));
));
}


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


var $form = $('<form>', { 'class': 'soldier-upload-form' });
function findSectionHeading(names) {
var $file = $('<input>', { type: 'file', name: 'ritchieUploadFile', 'class': 'soldier-upload-file' });
var re = new RegExp('^(' + names.join('|') + ')$', 'i');
var $profile = $('<label>').append(
var $found = $();
$('<input>', { type: 'checkbox', name: 'ritchieProfile', checked: true }),
$('h2').each(function () {
' Use as profile photo (images only)'
if (re.test(headingText($(this)))) {
);
$found = $(this);
var $gallery = $('<label>').append(
return false;
$('<input>', { type: 'checkbox', name: 'ritchieGallery' }),
}
' Add to Gallery (documents, or extra photos)'
});
);
return $found;
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() {
function ensureGalleryAnchor() {
var name = ($file.val() || '').split(/[/\\]/).pop();
var $h2 = $('#Gallery');
var isPhoto = PHOTO_EXT.test(name);
if (!$h2.length || !$h2.is('h2')) {
var isDoc = DOC_EXT.test(name);
$h2 = findSectionHeading(['Gallery']);
$profile.toggle(isPhoto || !name);
}
if (isDoc) {
if ($h2.length) {
$profile.prop('checked', false);
return headingWrap($h2);
$gallery.prop('checked', true);
}
} else if (isPhoto) {
var $anchor = findSectionHeading(['Technical record', 'Sources']);
$profile.prop('checked', true);
var $wrap = $('<div>', { 'class': 'mw-heading mw-heading2' });
$gallery.prop('checked', false);
$wrap.append($('<h2>', { id: 'Gallery' }).text('Gallery'));
if ($anchor.length) {
headingWrap($anchor).before($wrap);
} else {
$('.mw-parser-output').first().append($wrap);
}
}
return $wrap;
}
}


$file.on('change', refreshChecks);
function placeContributeBox($box) {
 
if ($('.soldier-upload-box').length) {
$form.on('submit', function (evt) {
return;
evt.preventDefault();
}
var file = $file[0].files[0];
/* Always directly under the Gallery heading — same place on every profile. */
if (!file) {
ensureGalleryAnchor().after($box);
setStatus('Choose a file first.', true);
}
return;
 
}
var pageTitle = mw.config.get('wgPageName');
if (!ALLOWED.test(file.name)) {
var loggedIn = !!mw.config.get('wgUserName');
setStatus('File type not allowed.', true);
var uploadUrl = mw.util.getUrl('Special:Upload') + '&wpRitchieSoldierPage=' + encodeURIComponent(pageTitle);
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);
var $box = $('<details>', { 'class': 'soldier-upload-box' });
setStatus('Uploading…');
var $summary = $('<summary>').text('Add photos or OCR a document');
$box.append($summary);


var api = new mw.Api();
var $body = $('<div>', { 'class': 'soldier-upload-body' });
var fileText = (isDoc ? 'Document' : 'Portrait') + ' for [[' + pageTitle + ']].';
$body.append($('<p>', { 'class': 'soldier-contrib-help' }).append(
api.postWithToken('csrf', {
document.createTextNode('Photos for the portrait or Gallery, or a document scan for OCR review. Also '),
action: 'upload',
$('<a>', { href: uploadUrl, text: 'Special:Upload' }),
filename: file.name,
document.createTextNode('.')
file: file,
));
comment: 'Uploaded from soldier page',
 
text: fileText,
var $photoSection = $('<div>', { 'class': 'soldier-contrib-section soldier-contrib-upload' });
format: 'json',
$photoSection.append($('<p>', { 'class': 'soldier-contrib-heading' }).append(
ignorewarnings: true
$('<strong>').text('Photo or document upload')
}, {
));
contentType: 'multipart/form-data'
 
}).then(function (data) {
if (!loggedIn) {
var uploaded = data.upload && data.upload.filename;
$photoSection.append($('<p>', { 'class': 'soldier-upload-login' }).html(
if (!uploaded) {
'<a href="' + mw.util.getUrl('Special:UserLogin', { returnto: pageTitle }) + '">Log in</a> to upload files or run document OCR.'
throw new Error('Upload failed');
));
}
$body.append($photoSection);
setStatus('Attaching to page…');
$body.append($('<div>', { 'class': 'soldier-ocr-slot' }));
return api.postWithToken('csrf', {
$box.append($body);
action: 'ritchieattachfile',
placeContributeBox($box);
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;
return;
}
}


var pageTitle = mw.config.get('wgPageName');
var $form = $('<form>', { 'class': 'soldier-upload-form' });
var editUrl = mw.util.getUrl(pageTitle, { action: 'edit' });
var $file = $('<input>', { type: 'file', name: 'ritchieUploadFile', 'class': 'soldier-upload-file' });
var sourceUrl = mw.util.getUrl(pageTitle, { action: 'edit', veaction: 'editsource' });
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' });


var FACT_GROUPS = [
$form.append($file, $profile, $gallery, $submit, $status);
{
$photoSection.append($form);
title: 'Personal details',
$body.append($photoSection);
fields: [
$body.append($('<div>', { 'class': 'soldier-ocr-slot' }));
{ key: 'alias', label: 'Birth name' },
$box.append($body);
{ key: 'birth_year', label: 'Birth year', hint: 'Four digits, e.g. 1924' },
placeContributeBox($box);
{ key: 'death_year', label: 'Death year', hint: 'Four digits, e.g. 2001' },
 
{ key: 'nativity', label: 'Nativity (birthplace)' },
function setStatus(msg, isError) {
{ key: 'religion', label: 'Religion' },
$status.text(msg).css('color', isError ? '#ba0000' : '#202122');
{ key: 'marital', label: 'Marital status' },
}
{ key: 'education', label: 'Education' },
 
{ key: 'occupation', label: 'Civilian occupation' },
function refreshChecks() {
{ key: 'state', label: 'State (residence)' }
var name = ($file.val() || '').split(/[/\\]/).pop();
]
var isPhoto = PHOTO_EXT.test(name);
},
var isDoc = DOC_EXT.test(name);
{
$profile.toggle(isPhoto || !name);
title: 'Military service',
if (isDoc) {
fields: [
$profile.prop('checked', false);
{ key: 'class_number', label: 'Class number(s)', hint: 'Semicolon for multiple, e.g. 5;11' },
$gallery.prop('checked', true);
{ key: 'class_section', label: 'Class section' },
} else if (isPhoto) {
{ key: 'class_rank', label: 'Class rank' },
$profile.prop('checked', true);
{ key: 'class_branch', label: 'Branch' },
$gallery.prop('checked', false);
{ 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' },
{ key: 'division', label: 'Unit / division' },
{ key: 'loc', label: 'LOC' },
{ key: 'date_arrive', label: 'Date arrive' },
{ key: 'date_depart', label: 'Date depart' }
]
}
}
];
}
 
$file.on('change', refreshChecks);


var $box = $('<div>', { 'class': 'soldier-facts-box' });
$form.on('submit', function (evt) {
var $intro = $('<p>').append(
evt.preventDefault();
$('<strong>').text('Editing this page'),
var file = $file[0].files[0];
document.createTextNode(' ')
if (!file) {
);
setStatus('Choose a file first.', true);
$intro.append(
return;
$('<button>', { type: 'button', 'class': 'soldier-facts-toggle', text: 'Edit soldier facts' }),
}
document.createTextNode(' (infobox fields), '),
if (!ALLOWED.test(file.name)) {
$('<a>', { href: editUrl, text: 'Edit biography' }),
setStatus('File type not allowed.', true);
document.createTextNode(' (visual editor for Life and notes), or '),
return;
$('<a>', { href: sourceUrl, text: 'Edit source' }),
}
document.createTextNode(' (full page markup).')
var isPhoto = PHOTO_EXT.test(file.name);
);
var isDoc = DOC_EXT.test(file.name);
$box.append($intro);
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 $draftBar = $('<p>', { 'class': 'soldier-summary-bar' });
var api = new mw.Api();
var $draftBtn = $('<button>', { type: 'button', 'class': 'soldier-summary-toggle', text: 'Draft opening summary' });
var fileText = (isDoc ? 'Document' : 'Portrait') + ' for [[' + pageTitle + ']].';
$draftBar.append(
api.postWithToken('csrf', {
$draftBtn,
action: 'upload',
document.createTextNode(' — optional 5–10 sentence opening from Personal details and the Biography section. Review before saving.')
filename: file.name,
);
file: file,
$box.append($draftBar);
comment: 'Uploaded from soldier page',
 
text: fileText,
var $draftPanel = $('<div>', { 'class': 'soldier-summary-panel', hidden: true });
format: 'json',
var $draftHint = $('<p>', { 'class': 'soldier-summary-hint', text: '' });
ignorewarnings: true
var $draftArea = $('<textarea>', { 'class': 'soldier-summary-text', rows: 8 });
}, {
var $draftStatus = $('<p>', { 'class': 'soldier-summary-status', text: '' });
contentType: 'multipart/form-data'
var $draftUse = $('<button>', { type: 'button', text: 'Use this summary' });
}).then(function (data) {
var $draftClose = $('<button>', { type: 'button', 'class': 'soldier-facts-cancel', text: 'Close' });
var uploaded = data.upload && data.upload.filename;
$draftPanel.append(
if (!uploaded) {
$draftHint,
throw new Error('Upload failed');
$draftArea,
}
$('<div>', { 'class': 'soldier-facts-actions' }).append($draftUse, ' ', $draftClose),
setStatus('Attaching to page…');
$draftStatus
return api.postWithToken('csrf', {
);
action: 'ritchieattachfile',
$box.append($draftPanel);
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);
});
});
});


var $panel = $('<div>', { 'class': 'soldier-facts-panel', hidden: true });
/**
var $identity = $('<p>', { 'class': 'soldier-facts-identity', text: '' });
* Soldier page easy-edit form — structured infobox fields without wikitext.
var $form = $('<form>', { 'class': 'soldier-facts-form' });
*/
var $status = $('<p>', { 'class': 'soldier-facts-status', text: '' });
$(function () {
var $actions = $('<div>', { 'class': 'soldier-facts-actions' });
if (mw.config.get('wgNamespaceNumber') !== 0 || !$('.soldier-infobox').length) {
var $save = $('<button>', { type: 'submit', text: 'Save facts' });
return;
var $cancel = $('<button>', { type: 'button', text: 'Cancel', 'class': 'soldier-facts-cancel' });
}
$actions.append($save, ' ', $cancel);
$form.append($identity);


$.each(FACT_GROUPS, function (_, group) {
var pageTitle = mw.config.get('wgPageName');
var $fieldset = $('<fieldset>', { 'class': 'soldier-facts-group' });
var editUrl = mw.util.getUrl(pageTitle, { action: 'edit' });
if (group.collapsed) {
var sourceUrl = mw.util.getUrl(pageTitle, { action: 'edit', veaction: 'editsource' });
$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);
var FACT_GROUPS = [
$panel.append($form);
{
$box.append($panel);
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 $heading = $('#firstHeading').first();
var $box = $('<div>', { 'class': 'soldier-facts-box' });
if ($heading.length) {
var $intro = $('<p>', { 'class': 'soldier-facts-intro' });
$heading.before($box);
$intro.append(
} else {
$('<strong>', { 'class': 'soldier-facts-label' }).text('Editing this page'),
var $output = $('.mw-parser-output').first();
$('<span>', { 'class': 'soldier-facts-help' }).text(' — '),
$output.prepend($box);
$('<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 loaded = false;
var $draftBar = $('<p>', { 'class': 'soldier-summary-bar' });
var loading = false;
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);


function setStatus(msg, isError) {
var $draftPanel = $('<div>', { 'class': 'soldier-summary-panel', hidden: true });
$status.text(msg).css('color', isError ? '#ba0000' : '#202122');
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: '' });
function loadFacts() {
var $draftUse = $('<button>', { type: 'button', text: 'Use this summary' });
if (loaded || loading) {
var $draftClose = $('<button>', { type: 'button', 'class': 'soldier-facts-cancel', text: 'Close' });
return $.Deferred().resolve().promise();
$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');
}
}
loading = true;
$fieldset.append($('<legend>').text(group.title));
setStatus('Loading…');
$.each(group.fields, function (__, field) {
return new mw.Api().get({
var id = 'ritchie-fact-' + field.key;
action: 'ritchiegetsoldierfacts',
var $label = $('<label>', { 'for': id, text: field.label });
title: pageTitle,
var $input = $('<input>', {
format: 'json'
type: 'text',
}).done(function (data) {
id: id,
var facts = data.ritchiegetsoldierfacts || {};
name: field.key,
var nameParts = $.grep([facts.first, facts.surname], Boolean);
'class': 'soldier-facts-input',
$identity.text(
'data-key': field.key
(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;
if (field.hint) {
setStatus('');
$input.attr('title', field.hint);
}).fail(function (code, data) {
$input.attr('placeholder', field.hint);
var err = (data && data.error && data.error.info) || code || 'Could not load facts';
}
setStatus(err, true);
$fieldset.append($('<div>', { 'class': 'soldier-facts-field' }).append($label, $input));
}).always(function () {
loading = false;
});
});
}
$form.append($fieldset);
});


$box.find('.soldier-facts-toggle').on('click', function () {
$form.append($actions, $status);
var open = !$panel.prop('hidden');
$panel.append($form);
if (open) {
$box.append($panel);
$panel.prop('hidden', true);
 
$(this).text('Edit soldier facts');
var $heading = $('#firstHeading').first();
setStatus('');
if ($heading.length) {
return;
$heading.before($box);
}
} else {
$panel.prop('hidden', false);
var $output = $('.mw-parser-output').first();
$(this).text('Hide soldier facts form');
$output.prepend($box);
loadFacts();
}
});


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


$form.on('submit', function (evt) {
function setStatus(msg, isError) {
evt.preventDefault();
$status.text(msg).css('color', isError ? '#ba0000' : '#202122');
var payload = {
}
action: 'ritchieeditsoldierfacts',
 
function loadFacts() {
if (loaded || loading) {
return $.Deferred().resolve().promise();
}
loading = true;
setStatus('Loading…');
return new mw.Api().get({
action: 'ritchiegetsoldierfacts',
title: pageTitle,
title: pageTitle,
format: 'json'
format: 'json'
};
}).done(function (data) {
$form.find('.soldier-facts-input').each(function () {
var facts = data.ritchiegetsoldierfacts || {};
payload[$(this).data('key')] = $(this).val();
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();
});
});


Line 1,331: Line 1,729:
return '';
return '';
}
}
var base = (window.__ocrPublicUrl || '').replace(/\/$/, '');
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) {
if (base) {
return base + path;
return base + path;
Line 1,340: Line 1,760:
function publicPreviewUrl(job) {
function publicPreviewUrl(job) {
return publicAssetUrl(job && job.preview_url);
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';
}
}


Line 1,353: Line 1,787:
{ id: 'other', label: 'Other document' }
{ 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) {
function changeLineForFact(fact) {
Line 1,358: Line 1,834:
var val = (fact.value || '').trim();
var val = (fact.value || '').trim();
var label = (fact.label || '').toLowerCase();
var label = (fact.label || '').toLowerCase();
if (fact.already_on_page) {
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';
return (fact.label || 'Already on page') + ' — skipped';
}
}
if (field === 'ipw_ge') {
if (field === 'ipw_ge') {
return 'Team: set IPW ' + val;
return 'Set their Team (IPW) to ' + val;
}
}
if (field === 'teams') {
if (field === 'teams') {
return 'Team: add ' + val + ' to teams';
return 'Add ' + val + ' to their Teams list';
}
}
if (field === 'asn') {
if (field === 'asn') {
return 'ASN: ' + val;
return 'Set ASN to ' + val;
}
}
if (field === 'rank_final') {
if (field === 'rank_final') {
return 'Rank: ' + val;
return 'Set rank to ' + val;
}
}
if (field === 'specialty') {
if (field === 'specialty') {
return 'Role: ' + val;
return 'Set specialty/role to ' + val;
}
}
if (field === 'class_number') {
if (field === 'class_number') {
return 'Class: ' + val;
return 'Set class to ' + val;
}
}
if (fact.kind === 'bio') {
if (fact.kind === 'bio') {
return 'Biography: ' + (val.length > 80 ? val.slice(0, 80) + '…' : val);
return 'Add to biography: ' + (val.length > 80 ? val.slice(0, 80) + '…' : val);
}
}
if (fact.kind === 'note') {
if (fact.kind === 'note') {
return (fact.label || 'Note') + ': ' + (val.length > 90 ? val.slice(0, 90) + '…' : val);
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) {
if (label.indexOf('team') !== -1 || label.indexOf('ipw') !== -1) {
Line 1,396: Line 1,876:
}
}


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


Line 1,432: Line 1,912:
}
}


function isWritableFact(fact) {
function isWritableFact(fact, applyNotes) {
return fact.kind === 'field' || fact.kind === 'bio';
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) {
function bindSoldierTypeahead($input, $suggest, onPick) {
Line 1,453: Line 1,958:
redirects: 'resolve'
redirects: 'resolve'
}).then(function (data) {
}).then(function (data) {
var titles = (data && data[1]) || [];
var titles = ((data && data[1]) || []).filter(function (t) {
return !rbIsMergedDuplicateTitle(t);
});
$suggest.empty();
$suggest.empty();
if (!titles.length) {
if (!titles.length) {
Line 1,475: Line 1,982:
function renderChangeRow(fact, job, opts) {
function renderChangeRow(fact, job, opts) {
opts = opts || {};
opts = opts || {};
var writable = isWritableFact(fact) && !fact.already_on_page;
var writable = isWritableFact(fact) && !factAlreadyOnPage(fact);
var forceSelected = opts.forceSelected;
var selected = writable && (forceSelected === undefined ? factSelected(fact) : !!forceSelected);
var $row = $('<div>', {
var $row = $('<div>', {
'class': 'ocr-fact-row' +
'class': 'ocr-fact-row' +
(fact.already_on_page ? ' ocr-fact-already' : '') +
(factAlreadyOnPage(fact) ? ' ocr-fact-already' : '') +
(writable ? ' ocr-fact-writable' : ' ocr-fact-note') +
(writable ? ' ocr-fact-writable' : ' ocr-fact-note') +
(isPrimaryTeamFact(fact) ? ' ocr-fact-primary' : ' ocr-fact-secondary') +
(fact.kind === 'note' ? ' ocr-fact-is-note' : ''),
(fact.kind === 'note' ? ' ocr-fact-is-note' : ''),
'data-id': fact.id,
'data-id': fact.id,
Line 1,488: Line 1,998:
var $check = $('<input>', {
var $check = $('<input>', {
type: 'checkbox',
type: 'checkbox',
checked: !!fact.selected && writable,
checked: selected,
disabled: !writable
disabled: !writable
});
});
var $line = $('<span>', { 'class': 'ocr-change-line' }).text(changeLineForFact(fact));
var $line = $('<span>', { 'class': 'ocr-change-line' }).text(
opts.plainSentence ? profileUpdateSentence(fact) : changeLineForFact(fact)
);
var $val = $('<input>', {
var $val = $('<input>', {
type: 'text',
type: 'text',
Line 1,503: Line 2,015:
});
});
var $details = $('<details>', { 'class': 'ocr-fact-details' });
var $details = $('<details>', { 'class': 'ocr-fact-details' });
$details.append($('<summary>').text('Edit'));
$details.append($('<summary>').text('Edit value'));
$details.append($('<label>').text('Value ').append($val));
$details.append($('<label>').text('Value ').append($val));
if (!opts.hideTargetVisible) {
var $tgtVis = $('<input>', {
type: 'text',
'class': 'ocr-fact-target-vis',
value: fact.target_title || job.bound_title || '',
placeholder: 'Soldier page title'
});
if (job.bound_title && !isInbox) {
$tgtVis.prop('readonly', true);
}
$tgtVis.on('input change', function () {
$tgt.val($.trim($tgtVis.val() || ''));
});
$details.append($('<label>').text('Page ').append($tgtVis));
}
$details.append($('<span>', { 'class': 'ocr-fact-meta' }).text(
$details.append($('<span>', { 'class': 'ocr-fact-meta' }).text(
(fact.kind || '') + ' · conf ' + (fact.confidence || 0) + '%'
'field ' + (fact.field || fact.kind || '') + ' · conf ' + (fact.confidence || 0) + '%'
));
));
if (writable || fact.already_on_page) {
if (writable || factAlreadyOnPage(fact)) {
$row.append($check, $line, $tgt, $details);
$row.append($check, $line, $tgt, $details);
} else {
} else {
$row.append($line, $tgt, $details);
$row.append($line, $tgt, $details);
$check.prop('checked', false);
}
}
/* Keep a hidden checkbox state for notes (never apply) */
if (!writable) {
if (!writable) {
$row.append($('<input>', { type: 'checkbox', checked: false, 'class': 'ocr-fact-check-hidden' }).hide());
$row.append($('<input>', { type: 'checkbox', checked: false, 'class': 'ocr-fact-check-hidden' }).hide());
Line 1,540: Line 2,035:
function renderPersonCards($list, proposed, job, onChange) {
function renderPersonCards($list, proposed, job, onChange) {
var grouped = groupFactsByPerson(proposed);
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) {
grouped.order.forEach(function (key) {
var items = grouped.byPerson[key];
var items = grouped.byPerson[key];
var first = items[0] || {};
var first = items[0] || {};
var title = first.target_title || '';
var title = first.target_title || '';
var writable = items.filter(function (f) {
var meta = personMetaFromItems(items);
return isWritableFact(f) && !f.already_on_page;
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) {
var notes = items.filter(function (f) {
return f.kind === 'note' || f.already_on_page;
return f.kind === 'note';
});
});
var included = writable.some(function (f) { return f.selected; });
var teamFact = teamFacts[0];
var $card = $('<div>', {
var teamDone = !!(teamFact && factAlreadyOnPage(teamFact));
'class': 'ocr-person-card' + (title ? ' ocr-person-matched' : ' ocr-person-unresolved'),
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
'data-person': key
});
});
var $head = $('<div>', { 'class': 'ocr-card-head' });
 
var $include = $('<input>', {
var $include = $('<input>', {
type: 'checkbox',
type: 'checkbox',
'class': 'ocr-include',
'class': 'ocr-include',
checked: included && !!title
checked: included,
title: teamDone ? 'This team is already on their profile' : 'Write the team onto their soldier page'
});
});
$head.append($('<label>', { 'class': 'ocr-include-label' }).append($include, ' Include'));
if (teamDone || !teamFact) {
$head.append($('<div>', { 'class': 'ocr-card-name' }).text(personDisplayName(first)));
$include.prop('disabled', true).prop('checked', false);
var meta = personMetaBits(items);
if (meta.length) {
$head.append($('<div>', { 'class': 'ocr-card-meta' }).text(meta.join(' · ')));
}
}
$card.append($head);


var $match = $('<div>', { 'class': 'ocr-card-match' });
var $tgtShared = $('<input>', {
var $tgtShared = $('<input>', {
type: 'text',
type: 'text',
'class': 'ocr-person-title',
'class': 'ocr-person-title',
value: title,
value: title,
placeholder: 'Find soldier (name or ASN)…'
placeholder: 'Search surname or ASN to match a page…'
});
});
var $suggest = $('<div>', { 'class': 'ocr-suggest' }).hide();
var $suggest = $('<div>', { 'class': 'ocr-suggest' }).hide();
var $pageLink = $('<div>', { 'class': 'ocr-wiki-page-name' });
var $matchStatus = $('<div>', { 'class': 'ocr-match-status' });
var $matchStatus = $('<div>', { 'class': 'ocr-match-status' });
function setMatch(t) {
var $outcome = $('<div>', { 'class': 'ocr-row-outcome' });
title = t || '';
 
$tgtShared.val(title);
function syncPrimaryChecks() {
$card.find('.ocr-fact-target').val(title);
var on = $include.prop('checked') && !!$.trim($tgtShared.val() || '') && !teamDone;
if (title) {
$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');
$card.removeClass('ocr-person-unresolved').addClass('ocr-person-matched');
$matchStatus.empty().append(
$pageLink.append($('<a>', {
$('<span>').text('Matched: '),
href: mw.util.getUrl(title),
$('<a>', { href: mw.util.getUrl(title), text: title, target: '_blank' })
text: title,
);
target: '_blank'
}));
$matchStatus.text('Open the page in a new tab to see their current profile.');
} else {
} else {
$card.removeClass('ocr-person-matched').addClass('ocr-person-unresolved');
$card.removeClass('ocr-person-matched').addClass('ocr-person-unresolved');
$matchStatus.text('Needs a soldier match before Apply');
$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);
$include.prop('checked', false);
syncPrimaryChecks();
}
}
if (onChange) {
refreshOutcome();
if (!silent && onChange) {
onChange();
onChange();
}
}
}
}
if (title) {
 
setMatch(title);
var $do = $('<td>', { 'class': 'ocr-col-do' });
} else {
$do.append($include);
$matchStatus.text('Needs a soldier match before Apply');
 
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(' · ')
));
}
}
$match.append($matchStatus);
 
$match.append($('<label>').text('Wiki page ').append($tgtShared));
var $page = $('<td>', { 'class': 'ocr-col-page' });
$match.append($suggest);
$page.append($pageLink, $matchStatus, $tgtShared, $suggest);
bindSoldierTypeahead($tgtShared, $suggest, setMatch);
bindSoldierTypeahead($tgtShared, $suggest, setMatch);
$tgtShared.on('change', function () {
$tgtShared.on('change', function () {
Line 1,617: Line 2,245:
if (candidates && candidates.length) {
if (candidates && candidates.length) {
var $pick = $('<select>', { 'class': 'ocr-match-pick' });
var $pick = $('<select>', { 'class': 'ocr-match-pick' });
$pick.append($('<option>', { value: '', text: 'Suggested matches…' }));
$pick.append($('<option>', { value: '', text: 'Suggested wiki pages…' }));
candidates.forEach(function (t) {
candidates.forEach(function (t) {
$pick.append($('<option>', { value: t, text: t, selected: t === title }));
$pick.append($('<option>', { value: t, text: t, selected: t === title }));
Line 1,626: Line 2,254:
}
}
});
});
$match.append($pick);
$page.append($pick);
}
}
$card.append($match);


var $changes = $('<div>', { 'class': 'ocr-card-changes' });
var $action = $('<td>', { 'class': 'ocr-col-action' });
writable.forEach(function (fact) {
$action.append($outcome);
var $row = renderChangeRow(fact, job, { hideTargetVisible: true });
 
$row.find('.ocr-fact-target').val(title);
var $store = $('<div>', { 'class': 'ocr-fact-store' });
$changes.append($row);
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);
});
});
if (!writable.length && notes.some(function (n) { return n.already_on_page; })) {
$action.append($store);
notes.filter(function (n) { return n.already_on_page; }).forEach(function (fact) {
$changes.append(renderChangeRow(fact, job, { hideTargetVisible: true }));
});
}
$card.append($changes);


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


$include.on('change', function () {
$include.on('change', function () {
var on = $include.prop('checked');
if ($include.prop('checked') && !$.trim($tgtShared.val() || '')) {
if (on && !$.trim($tgtShared.val() || '')) {
$include.prop('checked', false);
$include.prop('checked', false);
$matchStatus.text('Match a soldier page first, then Include.');
$matchStatus.text('Match a soldier page first, then check Write?.');
refreshOutcome();
return;
return;
}
}
$card.find('.ocr-fact-writable input[type="checkbox"]').prop('checked', on);
if ($include.prop('checked') && teamDone) {
$include.prop('checked', false);
refreshOutcome();
return;
}
syncPrimaryChecks();
refreshOutcome();
if (onChange) {
if (onChange) {
onChange();
onChange();
}
}
});
});
$card.on('change', 'input[type="checkbox"], input.ocr-fact-value', function () {
 
$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) {
if (onChange) {
onChange();
onChange();
}
}
});
});
$list.append($card);
$tbody.append($card);
setMatch(title, true);
});
});
$list.append($table);
}
}


Line 1,679: Line 2,329:
var $card = $('<div>', { 'class': 'ocr-person-card ocr-person-matched ocr-single-card' });
var $card = $('<div>', { 'class': 'ocr-person-card ocr-person-matched ocr-single-card' });
var title = job.bound_title || '';
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'));
$card.append($('<div>', { 'class': 'ocr-card-name' }).text(title || 'This soldier'));
if (title) {
if (title) {
Line 1,689: Line 2,340:


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


Line 1,699: Line 2,350:
}
}
primary.forEach(function (fact) {
primary.forEach(function (fact) {
var $row = renderChangeRow(fact, job, { hideTargetVisible: true });
var $row = renderChangeRow(fact, job, { hideTargetVisible: true, plainSentence: true });
$row.find('.ocr-fact-target').val(title);
$row.find('.ocr-fact-target').val(title);
$changes.append($row);
$changes.append($row);
});
});
skipped.forEach(function (fact) {
if (skipped.length) {
$changes.append(renderChangeRow(fact, job, { hideTargetVisible: true }));
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);
$card.append($changes);


Line 1,765: Line 2,421:
function renderReview($host, job, cfg) {
function renderReview($host, job, cfg) {
$host.empty();
$host.empty();
var $panel = $('<div>', { 'class': 'ocr-review-panel ocr-review-v2' });
var $panel = $('<div>', { 'class': 'ocr-review-panel ocr-review-v2' + (isMultiPersonJob(job) ? ' ocr-review-roster' : '') });
$panel.append($('<p>').append(
if (isMultiPersonJob(job)) {
$('<strong>').text(job.multi_person ? 'Confirm people, then Apply' : 'Confirm changes, then Apply'),
$panel.append($('<p>').append(
document.createTextNode(' — nothing is saved until you click Apply.')
$('<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 (job.multi_person && job.people_summary) {
if (isMultiPersonJob(job) && job.people_summary) {
var ps = job.people_summary;
var ps = job.people_summary;
$panel.append($('<p>', { 'class': 'ocr-people-summary' }).text(
var $banner = $('<div>', { 'class': 'ocr-outcome-banner' });
(ps.detected || 0) + ' people · ' +
$banner.append($('<strong>').text(
(ps.matched || 0) + ' matched · ' +
(ps.detected || 0) + ' people on this document'
(ps.unresolved || 0) + ' need review · ' +
(ps.already_on_page || 0) + ' already have team' +
(ps.teams && ps.teams.length ? (' · teams ' + ps.teams.join(', ')) : '') +
(ps.inferred_teams && ps.inferred_teams.length ? (' · inferred ' + ps.inferred_teams.join(', ')) : '')
));
));
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 {
} 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(' · ')
));
}
}
}


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


Line 1,830: Line 2,535:


var $previewBox = $('<div>', { 'class': 'ocr-apply-preview' });
var $previewBox = $('<div>', { 'class': 'ocr-apply-preview' });
$previewBox.append($('<strong>').text('What will change'));
$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' });
var $previewUl = $('<ul>', { 'class': 'ocr-apply-preview-list' });
$previewBox.append($previewUl);
$previewBox.append($previewCount, $previewUl);


function refreshPreview() {
function refreshPreview() {
var applyNotesOn = !!$panel.find('.ocr-apply-notes input').prop('checked');
var props = collectProposed();
var props = collectProposed();
var lines = [];
var byPage = {};
props.forEach(function (p) {
props.forEach(function (p) {
if (!p.selected || !isWritableFact(p) || p.already_on_page) {
if (!p.selected || !isWritableFact(p, applyNotesOn) || factAlreadyOnPage(p)) {
return;
return;
}
}
if (!(p.target_title || '').trim()) {
var page = (p.target_title || '').trim();
if (!page) {
return;
return;
}
}
var who = personDisplayName(p);
if (!byPage[page]) {
lines.push(who + ' → ' + changeLineForFact(p));
byPage[page] = [];
}
byPage[page].push(p);
});
});
var pages = Object.keys(byPage);
$previewUl.empty();
$previewUl.empty();
if (!lines.length) {
if (!pages.length) {
$previewUl.append($('<li>', { 'class': 'ocr-fact-meta' }).text('Nothing selected yet.'));
$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 {
} else {
lines.forEach(function (ln) {
$previewCount.text(
$previewUl.append($('<li>').text(ln));
'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);
});
});
}
}
}
}


if (job.multi_person) {
$facts.append($previewBox);
if (isMultiPersonJob(job)) {
var $toolbar = $('<div>', { 'class': 'ocr-multi-toolbar' });
var $toolbar = $('<div>', { 'class': 'ocr-multi-toolbar' });
var $selMatched = $('<button>', { type: 'button', text: 'Include all matched' });
var $selMatched = $('<button>', { type: 'button', text: 'Write team for every matched person who still needs it' });
var $deselUnres = $('<button>', { type: 'button', text: 'Exclude unmatched' });
var $deselUnres = $('<button>', { type: 'button', text: 'Clear unmatched' });
$toolbar.append($selMatched, $deselUnres);
$toolbar.append($selMatched, $deselUnres);
$facts.append($toolbar);
$facts.append($toolbar);
renderPersonCards($list, proposed, job, refreshPreview);
renderPersonCards($list, proposed, job, refreshPreview);
$selMatched.on('click', function () {
$selMatched.on('click', function () {
$list.find('.ocr-person-matched').each(function () {
$list.find('.ocr-person-matched:not(.ocr-person-done)').each(function () {
var $card = $(this);
var $card = $(this);
$card.find('.ocr-include').prop('checked', true).trigger('change');
$card.find('.ocr-include').prop('disabled', false).prop('checked', true).trigger('change');
$card.find('.ocr-fact-writable input[type="checkbox"]').prop('checked', true);
});
});
refreshPreview();
refreshPreview();
});
});
$deselUnres.on('click', function () {
$deselUnres.on('click', function () {
$list.find('.ocr-person-unresolved .ocr-include').prop('checked', false);
$list.find('.ocr-person-unresolved .ocr-include').prop('checked', false).trigger('change');
$list.find('.ocr-person-unresolved input[type="checkbox"]').prop('checked', false);
refreshPreview();
refreshPreview();
});
});
Line 1,881: Line 2,611:
}
}
$facts.append($list);
$facts.append($list);
$facts.append($previewBox);


/* Advanced: OCR text, doc type, gallery, overwrite, rebuild */
/* Advanced: OCR text, doc type, gallery, overwrite, rebuild */
Line 1,913: Line 2,642:


var $overwrite = $('<label>').append(
var $overwrite = $('<label>').append(
$('<input>', { type: 'checkbox', checked: !!job.overwrite }),
$('<input>', { type: 'checkbox', checked: mwApiBool(job.overwrite) }),
' Overwrite existing infobox fields (default: fill empty only)'
' Overwrite existing infobox fields (default: fill empty only)'
);
);
var $gallery = $('<label>').append(
var $gallery = $('<label>').append(
$('<input>', { type: 'checkbox', checked: job.add_to_gallery === true }),
$('<input>', { type: 'checkbox', checked: mwApiBool(job.add_to_gallery) }),
' Add document to Gallery on Apply' + (job.multi_person ? ' (off by default for multi-person)' : '')
' 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(
var $forceId = $('<label>', { 'class': 'ocr-force-identity' }).append(
$('<input>', { type: 'checkbox', checked: false }),
$('<input>', { type: 'checkbox', checked: false }),
' I confirmed this document matches this soldier (override identity guard)'
' I confirmed this document matches this soldier (override identity guard)'
);
);
if (job.identity_ok || job.multi_person) {
if (mwApiBool(job.identity_ok) || isMultiPersonJob(job)) {
$forceId.hide();
$forceId.hide();
}
}
$advBody.append($overwrite, $('<br>'), $gallery, $('<br>'), $forceId);
$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 $save = $('<button>', { type: 'button', text: 'Save draft' });
var $rebuild = $('<button>', { type: 'button', text: 'Rebuild from pages' });
var $rebuild = $('<button>', { type: 'button', text: 'Rebuild facts / re-OCR if handwriting checked' });
if (pages.length < 2) {
$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 1,943: Line 2,695:
var $actions = $('<div>', { 'class': 'ocr-review-actions' });
var $actions = $('<div>', { 'class': 'ocr-review-actions' });
var $discard = $('<button>', { type: 'button', text: 'Discard' });
var $discard = $('<button>', { type: 'button', text: 'Discard' });
var $dry = $('<button>', { type: 'button', text: 'Preview Apply' });
var $apply = $('<button>', {
var $apply = $('<button>', {
type: 'button',
type: 'button',
'class': 'ocr-apply-btn',
'class': 'ocr-apply-btn',
text: job.multi_person ? 'Apply selected' : 'Apply selected'
text: 'Apply selected'
});
});
var $status = $('<p>', { 'class': 'ocr-review-status' });
var $status = $('<p>', { 'class': 'ocr-review-status' });
if (!cfg.canApply) {
if (!cfg.canApply) {
$apply.prop('disabled', true);
$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.');
$status.text('Apply requires the ocr-reviewer group (or admin). Your upload stays in the review queue.');
}
}
$actions.append($discard, $apply, $status);
$actions.append($discard, $dry, $apply, $status);
$panel.append($actions);
$panel.append($actions);
$host.append($panel);
$host.append($panel);
Line 1,959: Line 2,713:


function collectProposed() {
function collectProposed() {
var applyNotesOn = !!$applyNotes.find('input').prop('checked');
var out = [];
var out = [];
$list.find('.ocr-fact-row').each(function () {
$list.find('.ocr-fact-row').each(function () {
Line 1,974: Line 2,729:
var $cb = $row.find('input[type="checkbox"]').first();
var $cb = $row.find('input[type="checkbox"]').first();
base.selected = $cb.length ? !!$cb.prop('checked') : false;
base.selected = $cb.length ? !!$cb.prop('checked') : false;
if ($row.hasClass('ocr-fact-is-note') || $row.hasClass('ocr-fact-note') && !base.already_on_page) {
if (base.kind === 'note' && !applyNotesOn) {
base.selected = false;
base.selected = false;
}
}
Line 2,012: Line 2,767:
doc_type: $docSelect.val(),
doc_type: $docSelect.val(),
gallery_caption: $.trim($caption.val() || ''),
gallery_caption: $.trim($caption.val() || ''),
force_identity: $forceId.find('input').prop('checked')
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')
};
};
}
}
Line 2,063: Line 2,821:
ocrPost({ op: 'discard', job_id: job.id }).then(function () {
ocrPost({ op: 'discard', job_id: job.id }).then(function () {
$host.empty().append($('<p>').text('Discarded.'));
$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 () {
$apply.on('click', function () {
if (!job.identity_ok && !$forceId.find('input').prop('checked')) {
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.');
$status.text('Confirm identity (Advanced → checkbox) before Apply, or fix the wrong document.');
$adv.prop('open', true);
$adv.prop('open', true);
Line 2,073: Line 2,873:
}
}
var selected = collectProposed().filter(function (p) {
var selected = collectProposed().filter(function (p) {
return p.selected && isWritableFact(p) && (p.target_title || '').trim();
return p.selected && isWritableFact(p, $applyNotes.find('input').prop('checked')) && (p.target_title || '').trim();
});
});
if (!selected.length) {
if (!selected.length) {
$status.text('Select at least one change with a matched soldier.');
$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;
return;
}
}
Line 2,153: Line 2,959:
}
}


function startUpload($fileInput, boundTitle, $host, cfg) {
function startUpload($fileInput, boundTitle, $host, cfg, opts) {
opts = opts || {};
var file = $fileInput[0].files[0];
var file = $fileInput[0].files[0];
if (!file) {
if (!file) {
Line 2,164: Line 2,971:
op: 'upload',
op: 'upload',
bound_title: boundTitle || '',
bound_title: boundTitle || '',
force_multi: opts.forceMulti ? true : false,
force_vision: opts.forceVision ? true : false,
file: file,
file: file,
format: 'json'
format: 'json'
Line 2,210: 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,220: Line 3,029:
'class': 'soldier-upload-file'
'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 $btn = $('<button>', { type: 'button', text: 'Upload & OCR' });
var $host = $('<div>', { 'class': 'ocr-review-host' });
var $host = $('<div>', { 'class': 'ocr-review-host' });
$section.append($file, $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'),
forceVision: $visionMode.find('input').prop('checked')
});
});
});
var $slot = $('.soldier-ocr-slot').first();
var $slot = $('.soldier-ocr-slot').first();
Line 2,246: 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 one soldier or a multi-person document (e.g. IPW team special orders). OCR groups people, matches by ASN when possible, and can Apply team membership to every matched profile in one pass.'
'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 2,262: Line 3,088:
'Resume ready jobs, or reopen a profile upload that finished after you left the page.'
'Resume ready jobs, or reopen a profile upload that finished after you left the page.'
));
));
var $ul = $('<ul>', { 'class': 'ocr-queue-list' });
var me = mw.config.get('wgUserName') || '';
$queue.append($ul);
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);
$slot.append($queue);


Line 2,276: Line 3,129:
}).then(function (bags) {
}).then(function (bags) {
$ul.empty();
$ul.empty();
var jobs = [].concat(bags.processing || [], bags.ready || []);
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) {
if (!jobs.length) {
$ul.append($('<li>').text('No ready or in-progress jobs.'));
$ul.append($('<li>').text('No jobs match these filters.'));
return;
return;
}
}
Line 2,294: Line 3,171:
var $meta = $('<div>', { 'class': 'ocr-queue-meta' });
var $meta = $('<div>', { 'class': 'ocr-queue-meta' });
var ps = job.people_summary || {};
var ps = job.people_summary || {};
var peopleBit = job.multi_person
var peopleBit = isMultiPersonJob(job)
? ((ps.detected || 0) + ' people · ' + (ps.matched || 0) + ' matched · ' + (ps.unresolved || 0) + ' unmatched')
? ((ps.detected || 0) + ' people · ' + (ps.matched || 0) + ' matched · ' + (ps.unresolved || 0) + ' unmatched')
: (job.bound_title ? job.bound_title : 'single document');
: (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(
$meta.append($('<div>').append(
$('<strong>').text(job.filename || job.id),
$('<strong>').text(job.filename || job.id),
document.createTextNode(' · ' + (job.doc_type_label || job.doc_type || 'document'))
document.createTextNode(
' · ' + (job.doc_type_label || job.doc_type || 'document') + kindBit
)
));
));
$meta.append($('<div>', { 'class': 'ocr-fact-meta' }).text(
$meta.append($('<div>', { 'class': 'ocr-fact-meta' }).text(
peopleBit + ' · ' + (job.status || '?') +
peopleBit + ' · ' + (job.status || '?') +
(job.status === 'processing' ? (' ' + (job.progress || 0) + '%') : '') +
(job.status === 'processing' ? (' ' + (job.progress || 0) + '%') : '') +
' · by ' + (job.uploader || '?')
' · 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' });
var $open = $('<button>', { type: 'button', text: 'Open' });
$meta.append($open);
$meta.append($open);
Line 2,325: Line 3,214:
});
});
}
}
$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();
loadQueue();
}
}
Line 2,331: Line 3,257:
});
});
});
});
(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. */

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. */