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

From Ritchie Boys Wiki
Revision as of 22:23, 2 September 2026 by Admin (talk | contribs) (Add live suggestions to Main Page Find a soldier)
Jump to navigation Jump to search

Note: After publishing, you may have to bypass your browser's cache to see the changes.

  • Firefox / Safari: Hold Shift while clicking Reload, or press either Ctrl-F5 or Ctrl-R (⌘-R on a Mac)
  • Google Chrome: Press Ctrl-Shift-R (⌘-Shift-R on a Mac)
  • Edge: Hold Ctrl while clicking Refresh, or press Ctrl-F5.
/**
 * 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.
 */
$(function () {
	(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 || []).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', function () {
			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();
			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 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 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);
			}
		}

		// 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() {
			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 '/index.php?title=' + encodeURIComponent('Team ' + name).replace(/%20/g, '_');
	}
	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/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.postWithFormData('csrf', {
			action: 'upload',
			filename: file.name,
			file: file,
			comment: 'Uploaded from soldier page',
			text: fileText,
			format: 'json'
		}).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' },
				{ key: 'marital', label: 'Marital status' },
				{ key: 'education', label: 'Education' },
				{ key: 'occupation', label: 'Civilian occupation' },
				{ key: 'state', label: 'State (residence)' }
			]
		},
		{
			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' },
				{ key: 'division', label: 'Unit / division' },
				{ key: 'loc', label: 'LOC' },
				{ key: 'date_arrive', label: 'Date arrive' },
				{ key: 'date_depart', label: 'Date depart' }
			]
		}
	];

	var $box = $('<div>', { 'class': 'soldier-facts-box' });
	var $intro = $('<p>').append(
		$('<strong>').text('Editing this page'),
		document.createTextNode(' — ')
	);
	$intro.append(
		$('<button>', { type: 'button', 'class': 'soldier-facts-toggle', text: 'Edit soldier facts' }),
		document.createTextNode(' (infobox fields), '),
		$('<a>', { href: editUrl, text: 'Edit biography' }),
		document.createTextNode(' (visual editor for Life and notes), or '),
		$('<a>', { href: sourceUrl, text: 'Edit source' }),
		document.createTextNode(' (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,
		document.createTextNode(' — 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().postWithFormData('csrf', formData).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 publicPreviewUrl(job) {
		if (!job || !job.preview_url) {
			return '';
		}
		// Prefer sidecar public URL from config when available
		var base = (window.__ocrPublicUrl || '').replace(/\/$/, '');
		if (base) {
			return base + job.preview_url;
		}
		return '';
	}

	function renderReview($host, job, cfg) {
		$host.empty();
		var $panel = $('<div>', { 'class': 'ocr-review-panel' });
		$panel.append($('<p>').append(
			$('<strong>').text('Review OCR results'),
			document.createTextNode(' — nothing is saved to a profile until you click Apply.')
		));
		$panel.append($('<p>', { 'class': 'ocr-fact-meta' }).text(
			'Status: ' + job.status +
			' · OCR quality ' + (job.ocr_quality || 0) + '%' +
			' · Confidence ' + (job.confidence || 0) + '%' +
			(job.error ? (' · Error: ' + job.error) : '')
		));

		var $layout = $('<div>', { 'class': 'ocr-review-layout' });
		var $prev = $('<div>', { 'class': 'ocr-review-preview' });
		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' });
		$facts.append($('<label>').text('OCR text (editable)'));
		var $text = $('<textarea>').val(job.ocr_text || '');
		$facts.append($text);

		var proposed = job.proposed || [];
		var $list = $('<div>');
		proposed.forEach(function (fact) {
			var $row = $('<div>', { 'class': 'ocr-fact-row', 'data-id': fact.id });
			var $check = $('<input>', { type: 'checkbox', checked: !!fact.selected });
			var $label = $('<span>').text((fact.label || fact.kind) + ' ');
			var $val = $('<input>', { type: 'text', value: fact.value || '' });
			var $tgt = $('<input>', {
				type: 'text',
				value: fact.target_title || job.bound_title || '',
				placeholder: 'Soldier page title'
			});
			if (job.bound_title && !isInbox) {
				$tgt.prop('readonly', true);
			}
			var $meta = $('<span>', { 'class': 'ocr-fact-meta' }).text(
				(fact.kind || '') + ' · conf ' + (fact.confidence || 0) + '%'
			);
			$row.append($check, $label, $val, $tgt, $meta);
			$list.append($row);
		});
		$facts.append($('<p>').append($('<strong>').text('Proposed facts')), $list);

		var $overwrite = $('<label>').append(
			$('<input>', { type: 'checkbox', checked: !!job.overwrite }),
			' Overwrite existing infobox fields'
		);
		var $gallery = $('<label>').append(
			$('<input>', { type: 'checkbox', checked: job.add_to_gallery !== false }),
			' Add document to Gallery on Apply'
		);
		$facts.append($overwrite, $('<br>'), $gallery);

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

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

		function collectProposed() {
			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;
				}
				base.selected = $row.find('input[type="checkbox"]').prop('checked');
				base.value = $.trim($row.find('input[type="text"]').eq(0).val() || '');
				base.target_title = $.trim($row.find('input[type="text"]').eq(1).val() || '');
				out.push(base);
			});
			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')
			};
		}

		$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));
			});
		});

		$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.'));
			});
		});

		$apply.on('click', function () {
			$status.text('Applying…');
			$apply.prop('disabled', true);
			ocrPost({
				op: 'apply',
				job_id: job.id,
				payload: JSON.stringify(payload())
			}).then(function (resp) {
				var j = (resp.job || resp);
				$status.text('Applied. ' + ((j.apply_log || []).join(' · ') || 'Done.'));
				if (isSoldier) {
					window.setTimeout(function () {
						window.location.reload();
					}, 1200);
				}
			}).catch(function (err) {
				$status.text(String(err && err.error && err.error.info || err));
				$apply.prop('disabled', false);
			});
		});
	}

	function pollJob($host, jobId, cfg) {
		$host.append($('<p>', { 'class': 'ocr-review-status' }).text('Running OCR…'));
		var tries = 0;
		function tick() {
			tries += 1;
			ocrGet({ op: 'job', job_id: jobId }).then(function (resp) {
				var job = resp.job || resp;
				if (job.status === 'queued' || job.status === 'processing') {
					if (tries > 90) {
						$host.empty().append($('<p>').text('OCR timed out. Try again.'));
						return;
					}
					$host.find('.ocr-review-status').text('Running OCR… (' + job.status + ')');
					window.setTimeout(tick, 2000);
					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) {
		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 || '',
			file: file,
			format: 'json'
		};
		ocrPost(null, fd).then(function (resp) {
			var job = (resp.job || resp);
			pollJob($host, job.id, cfg);
		}).catch(function (err) {
			$host.empty().append($('<p>').text(String(err && err.error && err.error.info || err)));
		});
	}

	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')
			));
			$section.append($('<p>', { 'class': 'soldier-contrib-help' }).text(
				'Scan or photo of a form, letter, or roster. Proposed facts appear here for review — nothing is written until Apply.'
			));
			var $file = $('<input>', {
				type: 'file',
				accept: '.jpg,.jpeg,.png,.gif,.webp,.tif,.tiff,.pdf',
				'class': 'soldier-upload-file'
			});
			var $btn = $('<button>', { type: 'button', text: 'Upload & OCR' });
			var $host = $('<div>', { 'class': 'ocr-review-host' });
			$section.append($file, $btn, $host);
			$btn.on('click', function () {
				startUpload($file, pageTitle, $host, cfg);
			});
			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(
				'For one soldier or several named on the same document. Review proposed facts and assign each to the correct profile before Apply.'
			));
			var $file2 = $('<input>', { type: 'file', accept: '.jpg,.jpeg,.png,.gif,.webp,.tif,.tiff,.pdf' });
			var $btn2 = $('<button>', { type: 'button', text: 'Upload & OCR' });
			var $host2 = $('<div>', { 'class': 'ocr-review-host' });
			$up.append($file2, ' ', $btn2, $host2);
			$btn2.on('click', function () {
				startUpload($file2, '', $host2, cfg);
			});
			$slot.append($up);

			var $queue = $('<div>', { 'class': 'ocr-review-box' });
			$queue.append($('<h3>').text('Review queue'));
			var $ul = $('<ul>', { 'class': 'ocr-queue-list' });
			$queue.append($ul);
			$slot.append($queue);

			function loadQueue() {
				ocrGet({ op: 'queue', status: 'ready' }).then(function (resp) {
					$ul.empty();
					var jobs = resp.jobs || [];
					if (!jobs.length) {
						$ul.append($('<li>').text('No ready jobs.'));
						return;
					}
					jobs.forEach(function (job) {
						var $li = $('<li>');
						var label = (job.filename || job.id) +
							(job.bound_title ? (' · ' + job.bound_title) : '') +
							' · conf ' + (job.confidence || 0) + '%' +
							' · by ' + (job.uploader || '?');
						var $open = $('<button>', { type: 'button', text: 'Open' });
						$li.append(document.createTextNode(label + ' '), $open);
						$open.on('click', function () {
							$host2.empty();
							renderReview($host2, job, cfg);
						});
						$ul.append($li);
					});
				});
			}
			loadQueue();
		}
	}).catch(function () {
		/* OCR service optional while wiki is up */
	});
});