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
Link MII/IPW team codes without combining titles
Add Internet Archive click-to-play embeds for interview watchlist
 
(81 intermediate revisions by the same user not shown)
Line 1: Line 1:
/**
/**
  * Hide leftover imported rows the infobox no longer shows.
  * Hide leftover imported rows the infobox no longer shows.
  * Put the research-notes disclaimer under that section heading.
  * 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.
  * 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 () {
(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 () {
$('.infobox th, .soldier-infobox th, table.wikitable td').each(function () {
var text = $(this).text().replace(/\s+/g, ' ').trim();
var text = $(this).text().replace(/\s+/g, ' ').trim();
Line 15: Line 1,072:
}
}
});
});
var $heading = $('#Research_notes').closest('h2');
var $disclaimer = $('.mw-parser-output > .research-notes-disclaimer').first();
if ($heading.length && $disclaimer.length) {
$disclaimer.insertAfter($heading);
}


function teamHref(name) {
function teamHref(name) {
return '/index.php?title=' + encodeURIComponent('Team ' + name).replace(/%20/g, '_');
return mw.util.getUrl('Team ' + name);
}
}
function teamLink(name, label) {
function teamLink(name, label) {
Line 30: Line 1,081:
function linkifyTeams(raw) {
function linkifyTeams(raw) {
var text = $('<div>').text(raw).html();
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) {
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 name = 'MII ' + num + '-' + letter.toUpperCase();
Line 41: Line 1,098:
return teamLink(name, num + '-' + letter.toUpperCase());
return teamLink(name, num + '-' + letter.toUpperCase());
});
});
text = text.replace(/\bIPW\s+(\d{1,4})\b/gi, function (_, num) {
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);
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;
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;
}
}


Line 78: Line 1,183:
});
});
});
});
return parts;
return joinDateFollowons(parts);
}
}


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

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