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
Soldier page upload box in Gallery section
Soldier facts easy-edit form and VisualEditor links
Line 426: Line 426:
setStatus(err, true);
setStatus(err, true);
$submit.prop('disabled', false);
$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: 'Nickname / alias' },
{ 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' },
{ key: 'ipw_it', label: 'IPW / Italian team' },
{ key: 'rank_final', label: 'Final rank' }
]
},
{
title: 'Additional record fields',
collapsed: true,
fields: [
{ key: 'other_class', label: 'Other class' },
{ key: 'division', label: '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 $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 $target = $('.soldier-infobox').first();
if ($target.length) {
$target.before($box);
} else {
$('.mw-parser-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);
});
});
});
});
});
});

Revision as of 03:40, 20 August 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).
 * Put the research-notes disclaimer under that section heading.
 * Split mashed CSV notes into separate bullets, then link MII/IPW team codes.
 */
$(function () {
	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] + ')';
	}
	if (mw.config.get('wgNamespaceNumber') === 14) {
		$('.mw-category-group li a').each(function () {
			var $a = $(this);
			var label = $a.text();
			if (!/\(\d/.test(label)) {
				return;
			}
			var pretty = prettySoldierLabel(label);
			if (pretty && pretty !== label) {
				$a.text(pretty);
				$a.attr('title', pretty);
			}
		});
	}

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

	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) {
		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)|awol|court[-\s]?martial|disciplin|discharged\s+for\s+cause|drop(?:ped)?\s+(?:out|from)|relieved|eliminat|unfit|misconduct|desert/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 upload box (Gallery section) — profile photos and documents.
 */
$(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;
	var $galleryHeading = $('#Gallery').closest('h2');
	if (!$galleryHeading.length) {
		$galleryHeading = $('h2').filter(function () {
			return $.trim($(this).text()).replace(/\[edit\]/i, '') === 'Gallery';
		}).first();
	}
	if (!$galleryHeading.length) {
		return;
	}

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

	var $box = $('<div>', { 'class': 'soldier-upload-box' });
	$box.append($('<p>').append(
		$('<strong>').text('Upload for this soldier'),
		document.createTextNode(' — photos (infobox portrait) or documents (PDF, Word, etc.). You can also use '),
		$('<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.'
		));
		$galleryHeading.after($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);
	$box.append($form);
	$galleryHeading.after($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: 'Nickname / alias' },
				{ 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' },
				{ key: 'ipw_it', label: 'IPW / Italian team' },
				{ key: 'rank_final', label: 'Final rank' }
			]
		},
		{
			title: 'Additional record fields',
			collapsed: true,
			fields: [
				{ key: 'other_class', label: 'Other class' },
				{ key: 'division', label: '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 $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 $target = $('.soldier-infobox').first();
	if ($target.length) {
		$target.before($box);
	} else {
		$('.mw-parser-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);
		});
	});
});