OCR roster table: who / wiki page / what changes on that profile
Add Internet Archive click-to-play embeds for interview watchlist
 
(33 intermediate revisions by the same user not shown)
Line 7: Line 7:
  * with live title suggestions (same idea as the header search box).
  * with live title suggestions (same idea as the header search box).
  * Reorder Special:Search hits so title matches and site pages rank above research-notes noise.
  * Reorder Special:Search hits so title matches and site pages rank above research-notes noise.
* Hide retired “(merged duplicate)” roster pages from find/search suggestions and results.
* Fill soldier Teammates rails from live Category:Team … membership (no roster-module rebuild).
* Inject site hub links into the Minerva hamburger (Sidebar is not used by Minerva 1.43).
  */
  */
function rbIsMergedDuplicateTitle(title) {
return /\(merged duplicate\)\s*$/i.test(String(title || ''));
}
$(function () {
$(function () {
(function injectMinervaSiteMenu() {
if (mw.config.get('skin') !== 'minerva') {
return;
}
var $drawer = $('#mw-mf-page-left');
var $after = $('#p-navigation');
if (!$drawer.length || !$after.length || $drawer.data('rbSiteMenu')) {
return;
}
$drawer.data('rbSiteMenu', 1);
var links = [
{ title: 'Contents', label: 'Contents', icon: 'listBullet' },
{ title: 'Start here', label: 'Start here', icon: 'lightbulb' },
{ title: 'Introduction', label: 'Introduction', icon: 'book' },
{ title: 'The Stories', label: 'Stories', icon: 'newspaper' },
{ title: 'Category:Classes', label: 'Classes', icon: 'userGroup' },
{ title: 'Photos', label: 'Photos', icon: 'imageGallery' },
{ title: 'Training materials', label: 'Training materials', icon: 'articles' },
{ title: 'Contribute', label: 'Contribute', icon: 'edit' },
{ title: 'Contact Us', label: 'Contact Us', icon: 'speechBubbles' },
{ title: 'About', label: 'About', icon: 'infoFilled' }
];
var $ul = $('<ul id="p-ritchie" class="toggle-list__list"></ul>');
links.forEach(function (item) {
var name = 'rb-' + String(item.title).toLowerCase().replace(/[^a-z0-9]+/g, '-');
var $a = $('<a class="toggle-list-item__anchor"></a>')
.addClass('menu__item--' + name)
.attr('href', mw.util.getUrl(item.title));
$a.append($('<span class="minerva-icon"></span>').addClass('minerva-icon--' + item.icon));
$a.append($('<span class="toggle-list-item__label"></span>').text(item.label));
$ul.append($('<li class="toggle-list-item"></li>').append($a));
});
$after.after($ul);
}());
(function buildHomeFind() {
(function buildHomeFind() {
var $slot = $('.mw-parser-output .home-find-slot').first();
var $slot = $('.mw-parser-output .home-find-slot').first();
Line 111: Line 153:
function rankTitles(query, hits) {
function rankTitles(query, hits) {
var q = String(query || '').trim().toLowerCase();
var q = String(query || '').trim().toLowerCase();
var scored = (hits || []).map(function (h) {
var scored = (hits || []).filter(function (h) {
return !rbIsMergedDuplicateTitle(h.title || '');
}).map(function (h) {
var t = h.title || '';
var t = h.title || '';
var low = t.toLowerCase();
var low = t.toLowerCase();
Line 284: Line 328:
function resort(siteTitles) {
function resort(siteTitles) {
var items = $list.children('li').get();
var items = $list.children('li').get();
items.forEach(function (el) {
if (rbIsMergedDuplicateTitle(titleFromItem(el))) {
$(el).remove();
}
});
items = $list.children('li').get();
if (items.length < 2) {
if (items.length < 2) {
return;
return;
Line 327: Line 377:
resort(siteTitles);
resort(siteTitles);
});
});
}());
(function emptySearchHelp() {
if (mw.config.get('wgCanonicalSpecialPageName') !== 'Search') {
return;
}
var params = new URLSearchParams(window.location.search);
var raw = String(params.get('search') || '').trim();
if (!raw) {
return;
}
var $results = $('.mw-search-results').first();
var noneFound = $('.mw-search-nonefound').length > 0
|| ($('.searchresults').length && !$results.length)
|| ($results.length && $results.children('li').length === 0);
if (!noneFound) {
return;
}
if ($('.rb-search-empty-help').length) {
return;
}
var contactUrl = mw.util.getUrl('Contact Us');
var startUrl = mw.util.getUrl('Start here') + '#If_the_name_is_not_found';
var $box = $('<div class="rb-search-empty-help" role="note"></div>');
$box.append($('<p></p>').append(
$('<strong></strong>').text('No soldier page matched “' + raw + '”.')
));
$box.append($('<ul></ul>').append(
$('<li></li>').text('Try alternate spellings, anglicized names, or maiden vs married surnames.'),
$('<li></li>').text('Try the ASN with or without leading zeros.'),
$('<li></li>').text('They may be missing from the Classes 1–31 roster import or not yet matched.')
));
$box.append($('<p></p>').append(
$('<a></a>').attr('href', startUrl).text('If the name is not found'),
document.createTextNode(' · '),
$('<a></a>').attr('href', contactUrl).text('Contact Us')
));
var $host = $('.mw-search-nonefound').first();
if ($host.length) {
$host.after($box);
} else if ($('.searchresults').length) {
$('.searchresults').first().prepend($box);
} else {
$('#mw-content-text').prepend($box);
}
}());
}());


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


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


function sectionAfter($h) {
$('.mw-parser-output .yt-embed[data-youtube-id]').each(function () {
var $nodes = $h.nextUntil('h2');
var $slot = $(this);
if ($nodes.length) {
if ($slot.data('rbYtEmbed')) {
return $nodes.last();
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 $h;
return null;
}
}


if ($awards.length && !$awards.data('rbAwardsPlaced')) {
var $cards = $('.mw-parser-output .yt-interview').filter(function () {
$awards.data('rbAwardsPlaced', 1);
return interviewCardRef($(this)) !== null;
var $bio = $out.find('h2').filter(function () {
});
var t = $(this).find('.mw-headline').text() || $(this).text();
$cards.each(function (idx) {
return /^(Biography|Life)\b/i.test($.trim(t));
var $card = $(this);
}).first();
if ($card.data('rbYtNext')) {
if ($bio.length) {
return;
sectionAfter($bio).after($awards);
}
} else {
$card.data('rbYtNext', 1);
var $before = $out.find('h2').filter(function () {
var $slot = $card.find('.yt-interview-next').first();
var t = $(this).find('.mw-headline').text() || $(this).text();
if (!$slot.length) {
return /^(Gallery|Research notes|Sources|Technical record)\b/i.test($.trim(t));
return;
}).first();
}
if ($before.length) {
var mine = splitSoldiers($card.attr('data-soldiers'));
$before.before($awards);
var selfRef = interviewCardRef($card);
} else {
var picks = [];
$out.append($awards);
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
});
}
}
}


// Class/team rail rides with awards (after Biography).
$cards.each(function (j) {
var $related = $out.find('.soldier-related').first();
if (j === idx || picks.length >= 3) {
if ($related.length && !$related.data('rbRelatedPlaced')) {
return;
$related.data('rbRelatedPlaced', 1);
}
var $awardsNow = $out.find('.soldier-awards-section').first();
var $other = $($cards.get(j));
if ($awardsNow.length) {
if (overlap(mine, splitSoldiers($other.attr('data-soldiers')))) {
$awardsNow.after($related);
addCard($other, 'same soldier');
} else {
}
var $bio2 = $out.find('h2').filter(function () {
});
var t = $(this).find('.mw-headline').text() || $(this).text();
if (idx + 1 < $cards.length) {
return /^(Biography|Life)\b/i.test($.trim(t));
addCard($($cards.get(idx + 1)), 'next');
}).first();
}
if ($bio2.length) {
if (!picks.length) {
sectionAfter($bio2).after($related);
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');
}
}


// Story cue belongs under the lead (above Biography).
$('.mw-parser-output .sc-embed[data-soundcloud-track]').each(function () {
var $storyCue = $out.find('.soldier-story-cue').first();
var $slot = $(this);
if ($storyCue.length && !$storyCue.data('rbStoryCuePlaced')) {
if ($slot.data('rbScEmbed')) {
$storyCue.data('rbStoryCuePlaced', 1);
return;
var $leadCue = $out.find('.soldier-lead, .page-lead').first();
if ($leadCue.length) {
$leadCue.after($storyCue);
}
}
}
var trackId = String($slot.attr('data-soundcloud-track') || '');
 
if (!SC_TRACK.test(trackId)) {
// Contents box belongs after the lead on soldier/profile pages only.
return;
// Other pages (e.g. Site changelog) keep MediaWiki's default TOC placement.
}
var $toc = $out.find('#toc, .toc, .mw-table-of-contents').first();
$slot.data('rbScEmbed', 1);
if ($toc.length && !$toc.data('rbTocPlaced')) {
var title = String($slot.attr('data-title') || 'SoundCloud audio');
$toc.data('rbTocPlaced', 1);
var $fallback = $slot.find('.sc-embed-fallback').first();
var $lead = $out.find('.soldier-lead, .page-lead, .home-lead').first();
var $btn = $('<button type="button" class="sc-embed-play"></button>')
if ($lead.length) {
.attr('aria-label', 'Play ' + title)
$lead.after($toc);
.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 {
} else {
var $ibox = $out.find('table.soldier-infobox, table.infobox').first();
$slot.prepend($poster);
if ($ibox.length) {
$ibox.after($toc);
}
}
}
}
$poster.on('click', function (ev) {
ev.preventDefault();
playSoundCloud($slot, trackId, title);
});
});
}());
}());


(function moveSoldierFooterCues() {
(function buildAviaryEmbeds() {
var $out = $('.mw-parser-output').first();
var AV_MEDIA = /^[0-9]{4,12}$/;
if (!$out.length) {
 
return;
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');
}
}
var $notes = $out.find('.research-notes-disclaimer').first();
 
var $sources = $out.find('.source-confidence-cue').first();
$('.mw-parser-output .aviary-embed[data-aviary-media]').each(function () {
if (!$notes.length && !$sources.length) {
var $slot = $(this);
return;
if ($slot.data('rbAviaryEmbed')) {
}
return;
var $footer = $out.find('.soldier-page-footer-notes').first();
}
if (!$footer.length) {
var mediaId = String($slot.attr('data-aviary-media') || '');
$footer = $('<div class="soldier-page-footer-notes"></div>');
if (!AV_MEDIA.test(mediaId)) {
$out.append($footer);
return;
}
}
if ($notes.length) {
$slot.data('rbAviaryEmbed', 1);
$footer.append($notes);
var title = String($slot.attr('data-title') || 'Aviary media');
}
var playLabel = String($slot.attr('data-play-label') || 'Play video');
if ($sources.length) {
var $fallback = $slot.find('.aviary-embed-fallback').first();
$footer.append($sources);
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);
});
});
}());
}());


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


Line 675: Line 1,074:


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


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


var $box = $('<div>', { 'class': 'soldier-facts-box' });
var $box = $('<div>', { 'class': 'soldier-facts-box' });
var $intro = $('<p>').append(
var $intro = $('<p>', { 'class': 'soldier-facts-intro' });
$('<strong>').text('Editing this page'),
document.createTextNode(' ')
);
$intro.append(
$intro.append(
$('<strong>', { 'class': 'soldier-facts-label' }).text('Editing this page'),
$('<span>', { 'class': 'soldier-facts-help' }).text(' — '),
$('<button>', { type: 'button', 'class': 'soldier-facts-toggle', text: 'Edit soldier facts' }),
$('<button>', { type: 'button', 'class': 'soldier-facts-toggle', text: 'Edit soldier facts' }),
document.createTextNode(' (infobox fields), '),
$('<span>', { 'class': 'soldier-facts-help' }).text(' (infobox fields), '),
$('<a>', { href: editUrl, text: 'Edit biography' }),
$('<a>', { href: editUrl, text: 'Edit biography' }),
document.createTextNode(' (visual editor for Life and notes), or '),
$('<span>', { 'class': 'soldier-facts-help' }).text(' (visual editor for Biography and notes), or '),
$('<a>', { href: sourceUrl, text: 'Edit source' }),
$('<a>', { href: sourceUrl, text: 'Edit source' }),
document.createTextNode(' (full page markup).')
$('<span>', { 'class': 'soldier-facts-help' }).text(' (full page markup).')
);
);
$box.append($intro);
$box.append($intro);
Line 1,084: Line 1,480:
$draftBar.append(
$draftBar.append(
$draftBtn,
$draftBtn,
document.createTextNode(' — optional 5–10 sentence opening from Personal details and the Biography section. Review before saving.')
$('<span>', { 'class': 'soldier-summary-help' }).text(
' — optional 5–10 sentence opening from Personal details and the Biography section. Review before saving.'
)
);
);
$box.append($draftBar);
$box.append($draftBar);
Line 1,330: Line 1,728:
if (!path) {
if (!path) {
return '';
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(/\/$/, '');
var base = (window.__ocrPublicUrl || '').replace(/\/$/, '');
Line 1,340: Line 1,760:
function publicPreviewUrl(job) {
function publicPreviewUrl(job) {
return publicAssetUrl(job && job.preview_url);
return publicAssetUrl(job && job.preview_url);
}
function timeAgo(ts) {
var sec = Math.max(0, Math.floor(Date.now() / 1000 - Number(ts || 0)));
if (!ts || sec < 60) {
return 'just now';
}
if (sec < 3600) {
return Math.floor(sec / 60) + 'm ago';
}
if (sec < 86400) {
return Math.floor(sec / 3600) + 'h ago';
}
return Math.floor(sec / 86400) + 'd ago';
}
}


Line 1,353: Line 1,787:
{ id: 'other', label: 'Other document' }
{ id: 'other', label: 'Other document' }
];
];
/** MediaWiki JSON encodes PHP true as "" and false as null/omitted. */
function mwApiBool(v) {
if (v === true || v === 1 || v === '1' || v === 'true') {
return true;
}
if (v === false || v === 0 || v === '0' || v === 'false' || v == null) {
return false;
}
if (v === '') {
return true;
}
return !!v;
}
function isMultiPersonJob(job) {
if (!job) {
return false;
}
if (mwApiBool(job.multi_person)) {
return true;
}
var detected = job.people_summary && job.people_summary.detected;
if (Number(detected) > 1) {
return true;
}
var keys = {};
(job.proposed || []).forEach(function (p) {
if (p.person_key) {
keys[p.person_key] = 1;
}
});
return Object.keys(keys).length > 1;
}
function factAlreadyOnPage(fact) {
return !!(fact && mwApiBool(fact.already_on_page));
}
function factSelected(fact) {
return !!(fact && mwApiBool(fact.selected));
}


function changeLineForFact(fact) {
function changeLineForFact(fact) {
Line 1,358: Line 1,834:
var val = (fact.value || '').trim();
var val = (fact.value || '').trim();
var label = (fact.label || '').toLowerCase();
var label = (fact.label || '').toLowerCase();
if (fact.already_on_page && (field === 'ipw_ge' || field === 'teams')) {
if (factAlreadyOnPage(fact) && (field === 'ipw_ge' || field === 'teams')) {
return 'Team already on their profile (' + (val || fact.existing_value || 'same') + ') — no change';
return 'Team already on their profile (' + (val || fact.existing_value || 'same') + ') — no change';
}
}
if (fact.already_on_page) {
if (factAlreadyOnPage(fact)) {
return (fact.label || 'Already on page') + ' — skipped';
return (fact.label || 'Already on page') + ' — skipped';
}
}
Line 1,386: Line 1,862:
}
}
if (fact.kind === 'note') {
if (fact.kind === 'note') {
return (fact.label || 'Note') + ': ' + (val.length > 90 ? val.slice(0, 90) + '…' : val);
return 'Add research note — ' + (fact.label || 'OCR note') + ': ' +
(val.length > 90 ? val.slice(0, 90) + '…' : val);
}
}
if (label.indexOf('team') !== -1 || label.indexOf('ipw') !== -1) {
if (label.indexOf('team') !== -1 || label.indexOf('ipw') !== -1) {
Line 1,435: Line 1,912:
}
}


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


Line 1,449: Line 1,932:
}
}
var val = (fact.value || '').trim();
var val = (fact.value || '').trim();
if (fact.already_on_page) {
if (factAlreadyOnPage(fact)) {
return 'Their profile already lists this team — nothing to write.';
return 'Their profile already lists this team — nothing to write.';
}
}
Line 1,475: Line 1,958:
redirects: 'resolve'
redirects: 'resolve'
}).then(function (data) {
}).then(function (data) {
var titles = (data && data[1]) || [];
var titles = ((data && data[1]) || []).filter(function (t) {
return !rbIsMergedDuplicateTitle(t);
});
$suggest.empty();
$suggest.empty();
if (!titles.length) {
if (!titles.length) {
Line 1,497: Line 1,982:
function renderChangeRow(fact, job, opts) {
function renderChangeRow(fact, job, opts) {
opts = opts || {};
opts = opts || {};
var writable = isWritableFact(fact) && !fact.already_on_page;
var writable = isWritableFact(fact) && !factAlreadyOnPage(fact);
var forceSelected = opts.forceSelected;
var forceSelected = opts.forceSelected;
var selected = writable && (forceSelected === undefined ? !!fact.selected : !!forceSelected);
var selected = writable && (forceSelected === undefined ? factSelected(fact) : !!forceSelected);
var $row = $('<div>', {
var $row = $('<div>', {
'class': 'ocr-fact-row' +
'class': 'ocr-fact-row' +
(fact.already_on_page ? ' ocr-fact-already' : '') +
(factAlreadyOnPage(fact) ? ' ocr-fact-already' : '') +
(writable ? ' ocr-fact-writable' : ' ocr-fact-note') +
(writable ? ' ocr-fact-writable' : ' ocr-fact-note') +
(isPrimaryTeamFact(fact) ? ' ocr-fact-primary' : ' ocr-fact-secondary') +
(isPrimaryTeamFact(fact) ? ' ocr-fact-primary' : ' ocr-fact-secondary') +
Line 1,535: Line 2,020:
'field ' + (fact.field || fact.kind || '') + ' · conf ' + (fact.confidence || 0) + '%'
'field ' + (fact.field || fact.kind || '') + ' · conf ' + (fact.confidence || 0) + '%'
));
));
if (writable || fact.already_on_page) {
if (writable || factAlreadyOnPage(fact)) {
$row.append($check, $line, $tgt, $details);
$row.append($check, $line, $tgt, $details);
} else {
} else {
Line 1,569: Line 2,054:
var teamFacts = items.filter(isPrimaryTeamFact);
var teamFacts = items.filter(isPrimaryTeamFact);
var secondary = items.filter(function (f) {
var secondary = items.filter(function (f) {
return isWritableFact(f) && !isPrimaryTeamFact(f) && !f.already_on_page;
return isWritableFact(f) && !isPrimaryTeamFact(f) && !factAlreadyOnPage(f);
});
});
var notes = items.filter(function (f) {
var notes = items.filter(function (f) {
Line 1,575: Line 2,060:
});
});
var teamFact = teamFacts[0];
var teamFact = teamFacts[0];
var teamDone = !!(teamFact && teamFact.already_on_page);
var teamDone = !!(teamFact && factAlreadyOnPage(teamFact));
var teamReady = !!(teamFact && !teamFact.already_on_page && (teamFact.value || '').trim());
var teamReady = !!(teamFact && !factAlreadyOnPage(teamFact) && (teamFact.value || '').trim());
var included = !!(title && teamReady && teamFact.selected !== false);
var included = !!(title && teamReady && factSelected(teamFact));
var teamLabel = (teamFact && (teamFact.value || '').trim())
var teamLabel = (teamFact && (teamFact.value || '').trim())
? ('IPW ' + String(teamFact.value).replace(/^IPW\s+/i, ''))
? ('IPW ' + String(teamFact.value).replace(/^IPW\s+/i, ''))
Line 1,653: Line 2,138:
(teamFact && teamFact.value ? (', then Team ' + teamLabel + ' can be added.') : '.')
(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;
return;
}
}
Line 1,838: Line 2,340:


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


Line 1,919: Line 2,421:
function renderReview($host, job, cfg) {
function renderReview($host, job, cfg) {
$host.empty();
$host.empty();
var $panel = $('<div>', { 'class': 'ocr-review-panel ocr-review-v2' + (job.multi_person ? ' ocr-review-roster' : '') });
var $panel = $('<div>', { 'class': 'ocr-review-panel ocr-review-v2' + (isMultiPersonJob(job) ? ' ocr-review-roster' : '') });
if (job.multi_person) {
if (isMultiPersonJob(job)) {
$panel.append($('<p>').append(
$panel.append($('<p>').append(
$('<strong>').text('Each row is one person'),
$('<strong>').text('Each row is one person'),
Line 1,931: Line 2,433:
$('<strong>').text('Confirm changes, then Apply'),
$('<strong>').text('Confirm changes, then Apply'),
document.createTextNode(' — nothing is saved until you click Apply.')
document.createTextNode(' — nothing is saved until you click Apply.')
));
}
if (job.source_kind === 'facebook_seed') {
$panel.append($('<div>', { 'class': 'ocr-seed-banner' }).append(
$('<strong>').text('Facebook OCR seed'),
document.createTextNode(
' — review #' + (job.source_ref || '?') +
'. Text is from a prior gallery OCR; verify against the File on the soldier page before Apply.'
)
));
));
}
}


if (job.multi_person && job.people_summary) {
if (isMultiPersonJob(job) && job.people_summary) {
var ps = job.people_summary;
var ps = job.people_summary;
var $banner = $('<div>', { 'class': 'ocr-outcome-banner' });
var $banner = $('<div>', { 'class': 'ocr-outcome-banner' });
Line 1,954: Line 2,465:
$panel.append($banner);
$panel.append($banner);
} else {
} else {
var engineBit = job.ocr_engine ? (' · Engine: ' + job.ocr_engine) : '';
$panel.append($('<p>', { 'class': 'ocr-fact-meta' }).text(
$panel.append($('<p>', { 'class': 'ocr-fact-meta' }).text(
(job.doc_type_label || 'Document') +
(job.doc_type_label || 'Document') +
' · OCR quality ' + (job.ocr_quality || 0) + '%' +
' · OCR quality ' + (job.ocr_quality || 0) + '%' +
engineBit +
(job.error ? (' · Error: ' + job.error) : '')
(job.error ? (' · Error: ' + job.error) : '')
));
));
if (job.ocr_engine_notes && job.ocr_engine_notes.length) {
$panel.append($('<p>', { 'class': 'ocr-fact-meta' }).text(
job.ocr_engine_notes.slice(0, 3).join(' · ')
));
}
}
}


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


Line 2,004: Line 2,537:
$previewBox.append($('<strong>').text('Soldier pages that will be edited'));
$previewBox.append($('<strong>').text('Soldier pages that will be edited'));
$previewBox.append($('<p>', { 'class': 'ocr-fact-meta ocr-preview-hint' }).text(
$previewBox.append($('<p>', { 'class': 'ocr-fact-meta ocr-preview-hint' }).text(
job.multi_person
isMultiPersonJob(job)
? 'Grouped by wiki page. Rank/role/ASN from the scan are not listed here unless you check them under a row.'
? '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.'
: 'Checked items below will be written on Apply.'
Line 2,013: Line 2,546:


function refreshPreview() {
function refreshPreview() {
var applyNotesOn = !!$panel.find('.ocr-apply-notes input').prop('checked');
var props = collectProposed();
var props = collectProposed();
var byPage = {};
var byPage = {};
props.forEach(function (p) {
props.forEach(function (p) {
if (!p.selected || !isWritableFact(p) || p.already_on_page) {
if (!p.selected || !isWritableFact(p, applyNotesOn) || factAlreadyOnPage(p)) {
return;
return;
}
}
Line 2,033: Line 2,567:
$previewCount.text('');
$previewCount.text('');
$previewUl.append($('<li>', { 'class': 'ocr-fact-meta' }).text(
$previewUl.append($('<li>', { 'class': 'ocr-fact-meta' }).text(
job.multi_person
isMultiPersonJob(job)
? 'Nothing will be written on Apply. Check Write? on a row (after matching a wiki page) to add that person.'
? 'Nothing will be written on Apply. Check Write? on a row (after matching a wiki page) to add that person.'
: 'Nothing selected yet.'
: 'Nothing selected yet.'
Line 2,055: Line 2,589:


$facts.append($previewBox);
$facts.append($previewBox);
if (job.multi_person) {
if (isMultiPersonJob(job)) {
var $toolbar = $('<div>', { 'class': 'ocr-multi-toolbar' });
var $toolbar = $('<div>', { 'class': 'ocr-multi-toolbar' });
var $selMatched = $('<button>', { type: 'button', text: 'Write team for every matched person who still needs it' });
var $selMatched = $('<button>', { type: 'button', text: 'Write team for every matched person who still needs it' });
Line 2,108: Line 2,642:


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


var $save = $('<button>', { type: 'button', text: 'Save draft' });
var $save = $('<button>', { type: 'button', text: 'Save draft' });
var $rebuild = $('<button>', { type: 'button', text: 'Rebuild from pages' });
var $rebuild = $('<button>', { type: 'button', text: 'Rebuild facts / re-OCR if handwriting checked' });
if (pages.length < 2) {
$rebuild.hide();
}
$advBody.append($('<div>', { 'class': 'ocr-advanced-actions' }).append($save, $rebuild));
$advBody.append($('<div>', { 'class': 'ocr-advanced-actions' }).append($save, $rebuild));
$adv.append($advBody);
$adv.append($advBody);
Line 2,138: Line 2,695:
var $actions = $('<div>', { 'class': 'ocr-review-actions' });
var $actions = $('<div>', { 'class': 'ocr-review-actions' });
var $discard = $('<button>', { type: 'button', text: 'Discard' });
var $discard = $('<button>', { type: 'button', text: 'Discard' });
var $dry = $('<button>', { type: 'button', text: 'Preview Apply' });
var $apply = $('<button>', {
var $apply = $('<button>', {
type: 'button',
type: 'button',
'class': 'ocr-apply-btn',
'class': 'ocr-apply-btn',
text: job.multi_person ? 'Apply selected' : 'Apply selected'
text: 'Apply selected'
});
});
var $status = $('<p>', { 'class': 'ocr-review-status' });
var $status = $('<p>', { 'class': 'ocr-review-status' });
if (!cfg.canApply) {
if (!cfg.canApply) {
$apply.prop('disabled', true);
$apply.prop('disabled', true);
$dry.prop('disabled', true);
$status.text('Apply requires the ocr-reviewer group (or admin). Your upload stays in the review queue.');
$status.text('Apply requires the ocr-reviewer group (or admin). Your upload stays in the review queue.');
}
}
$actions.append($discard, $apply, $status);
$actions.append($discard, $dry, $apply, $status);
$panel.append($actions);
$panel.append($actions);
$host.append($panel);
$host.append($panel);
Line 2,154: Line 2,713:


function collectProposed() {
function collectProposed() {
var applyNotesOn = !!$applyNotes.find('input').prop('checked');
var out = [];
var out = [];
$list.find('.ocr-fact-row').each(function () {
$list.find('.ocr-fact-row').each(function () {
Line 2,169: Line 2,729:
var $cb = $row.find('input[type="checkbox"]').first();
var $cb = $row.find('input[type="checkbox"]').first();
base.selected = $cb.length ? !!$cb.prop('checked') : false;
base.selected = $cb.length ? !!$cb.prop('checked') : false;
if ($row.hasClass('ocr-fact-is-note') || ($row.hasClass('ocr-fact-note') && !base.already_on_page && base.kind === 'note')) {
if (base.kind === 'note' && !applyNotesOn) {
base.selected = false;
base.selected = false;
}
}
Line 2,207: Line 2,767:
doc_type: $docSelect.val(),
doc_type: $docSelect.val(),
gallery_caption: $.trim($caption.val() || ''),
gallery_caption: $.trim($caption.val() || ''),
force_identity: $forceId.find('input').prop('checked')
force_identity: $forceId.find('input').prop('checked'),
force_multi: $forceMulti.find('input').prop('checked'),
force_vision: $forceVision.find('input').prop('checked'),
apply_notes: $applyNotes.find('input').prop('checked')
};
};
}
}
Line 2,261: Line 2,824:
});
});


$apply.on('click', function () {
$dry.on('click', function () {
if (!job.multi_person && !job.identity_ok && !$forceId.find('input').prop('checked')) {
$status.text('Building Apply preview…');
$status.text('Confirm identity (Advanced → checkbox) before Apply, or fix the wrong document.');
$dry.prop('disabled', true);
$adv.prop('open', true);
ocrPost({
return;
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) {
var selected = collectProposed().filter(function (p) {
return p.selected && isWritableFact(p) && (p.target_title || '').trim();
return p.selected && isWritableFact(p, $applyNotes.find('input').prop('checked')) && (p.target_title || '').trim();
});
});
if (!selected.length) {
if (!selected.length) {
$status.text('Select at least one change with a matched soldier.');
$status.text('Select at least one change with a matched soldier.');
return;
}
var nPages = {};
selected.forEach(function (p) { nPages[p.target_title] = 1; });
var pageCount = Object.keys(nPages).length;
if (pageCount > 5 && !window.confirm('Apply changes to ' + pageCount + ' soldier pages?')) {
return;
return;
}
}
Line 2,348: Line 2,959:
}
}


function startUpload($fileInput, boundTitle, $host, cfg) {
function startUpload($fileInput, boundTitle, $host, cfg, opts) {
opts = opts || {};
var file = $fileInput[0].files[0];
var file = $fileInput[0].files[0];
if (!file) {
if (!file) {
Line 2,359: Line 2,971:
op: 'upload',
op: 'upload',
bound_title: boundTitle || '',
bound_title: boundTitle || '',
force_multi: opts.forceMulti ? true : false,
force_vision: opts.forceVision ? true : false,
file: file,
file: file,
format: 'json'
format: 'json'
Line 2,405: Line 3,019:
var $section = $('<div>', { 'class': 'soldier-contrib-section soldier-contrib-ocr' });
var $section = $('<div>', { 'class': 'soldier-contrib-section soldier-contrib-ocr' });
$section.append($('<p>', { 'class': 'soldier-contrib-heading' }).append(
$section.append($('<p>', { 'class': 'soldier-contrib-heading' }).append(
$('<strong>').text('OCR a document')
$('<strong>').text('OCR a document to enrich this profile')
));
));
$section.append($('<p>', { 'class': 'soldier-contrib-help' }).text(
$section.append($('<p>', { 'class': 'soldier-contrib-help' }).text(
'Scan or photo of a form, letter, or roster. Proposed facts appear here for review — nothing is written until Apply.'
'Scan a form, letter, certificate, clipping, or roster. Review proposes infobox facts and a short biography draft — nothing is written until Apply. Team rosters can update many soldiers at once.'
));
));
var $file = $('<input>', {
var $file = $('<input>', {
Line 2,415: Line 3,029:
'class': 'soldier-upload-file'
'class': 'soldier-upload-file'
});
});
var $rosterMode = $('<label>', { 'class': 'ocr-force-multi-upload' }).append(
$('<input>', { type: 'checkbox' }),
' This is a multi-person roster (not only this soldier)'
);
var $visionMode = $('<label>', { 'class': 'ocr-force-multi-upload' }).append(
$('<input>', { type: 'checkbox' }),
' Handwriting / hard scan (free print OCR first; vision only if needed)'
);
var $btn = $('<button>', { type: 'button', text: 'Upload & OCR' });
var $btn = $('<button>', { type: 'button', text: 'Upload & OCR' });
var $host = $('<div>', { 'class': 'ocr-review-host' });
var $host = $('<div>', { 'class': 'ocr-review-host' });
$section.append($file, $btn, $host);
$section.append($file, $rosterMode, $visionMode, $btn, $host);
$btn.on('click', function () {
$btn.on('click', function () {
startUpload($file, pageTitle, $host, cfg);
startUpload($file, pageTitle, $host, cfg, {
forceMulti: $rosterMode.find('input').prop('checked'),
forceVision: $visionMode.find('input').prop('checked')
});
});
});
var $slot = $('.soldier-ocr-slot').first();
var $slot = $('.soldier-ocr-slot').first();
Line 2,441: Line 3,066:
$up.append($('<h3>').text('Upload a document'));
$up.append($('<h3>').text('Upload a document'));
$up.append($('<p>').text(
$up.append($('<p>').text(
'For a roster, review is a table: who is on the scan, which wiki page, and what would be written on that profile (usually Team). Rank, role, and ASN are matching cues, not automatic edits.'
'Enrich soldier profiles from scans: propose infobox facts, a short biography draft, and Sources. For multi-person IPW rosters, review is a table (usually Team membership). Rank, role, and ASN are matching cues until you approve.'
));
));
var $file2 = $('<input>', { type: 'file', accept: '.jpg,.jpeg,.png,.gif,.webp,.tif,.tiff,.pdf' });
var $file2 = $('<input>', { type: 'file', accept: '.jpg,.jpeg,.png,.gif,.webp,.tif,.tiff,.pdf' });
var $vision2 = $('<label>', { 'class': 'ocr-force-multi-upload' }).append(
$('<input>', { type: 'checkbox' }),
' Handwriting / hard scan (vision only if print OCR is weak)'
);
var $btn2 = $('<button>', { type: 'button', text: 'Upload & OCR' });
var $btn2 = $('<button>', { type: 'button', text: 'Upload & OCR' });
var $host2 = $('<div>', { 'class': 'ocr-review-host' });
var $host2 = $('<div>', { 'class': 'ocr-review-host' });
$up.append($file2, ' ', $btn2, $host2);
$up.append($file2, $vision2, ' ', $btn2, $host2);
$btn2.on('click', function () {
$btn2.on('click', function () {
startUpload($file2, '', $host2, cfg);
startUpload($file2, '', $host2, cfg, {
forceVision: $vision2.find('input').prop('checked')
});
});
});
$slot.append($up);
$slot.append($up);
Line 2,457: Line 3,088:
'Resume ready jobs, or reopen a profile upload that finished after you left the page.'
'Resume ready jobs, or reopen a profile upload that finished after you left the page.'
));
));
var 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' });
var $ul = $('<ul>', { 'class': 'ocr-queue-list' });
$queue.append($ul);
$queue.append($filters, $clearRow, $ul);
$slot.append($queue);
$slot.append($queue);


Line 2,471: Line 3,129:
}).then(function (bags) {
}).then(function (bags) {
$ul.empty();
$ul.empty();
var jobs = [].concat(bags.processing || [], bags.ready || []);
var showMine = $filterMine.find('input').prop('checked');
var showReady = $filterReady.find('input').prop('checked');
var showProc = $filterProc.find('input').prop('checked');
var jobs = [];
if (showProc) {
jobs = jobs.concat(bags.processing || []);
}
if (showReady) {
jobs = jobs.concat(bags.ready || []);
}
if (showMine) {
jobs = jobs.filter(function (j) {
return (j.uploader || '') === me;
});
}
var readyCount = (bags.ready || []).filter(function (j) {
return !showMine || (j.uploader || '') === me;
}).length;
$clearReady.text(
showMine
? ('Clear my ready (' + readyCount + ')')
: ('Clear ready queue (' + readyCount + ')')
);
$clearReady.data('readyCount', readyCount);
$clearReady.data('mineOnly', showMine);
if (!jobs.length) {
if (!jobs.length) {
$ul.append($('<li>').text('No ready or in-progress jobs.'));
$ul.append($('<li>').text('No jobs match these filters.'));
return;
return;
}
}
Line 2,489: Line 3,171:
var $meta = $('<div>', { 'class': 'ocr-queue-meta' });
var $meta = $('<div>', { 'class': 'ocr-queue-meta' });
var ps = job.people_summary || {};
var ps = job.people_summary || {};
var peopleBit = job.multi_person
var peopleBit = isMultiPersonJob(job)
? ((ps.detected || 0) + ' people · ' + (ps.matched || 0) + ' matched · ' + (ps.unresolved || 0) + ' unmatched')
? ((ps.detected || 0) + ' people · ' + (ps.matched || 0) + ' matched · ' + (ps.unresolved || 0) + ' unmatched')
: (job.bound_title ? job.bound_title : 'single document');
: (job.bound_title ? job.bound_title : 'single document');
var mineBit = (job.uploader || '') === me ? ' · you' : '';
var kindBit = job.source_kind === 'facebook_seed'
? ' · FB seed' + (job.source_ref ? (' #' + job.source_ref) : '')
: '';
$meta.append($('<div>').append(
$meta.append($('<div>').append(
$('<strong>').text(job.filename || job.id),
$('<strong>').text(job.filename || job.id),
document.createTextNode(' · ' + (job.doc_type_label || job.doc_type || 'document'))
document.createTextNode(
' · ' + (job.doc_type_label || job.doc_type || 'document') + kindBit
)
));
));
$meta.append($('<div>', { 'class': 'ocr-fact-meta' }).text(
$meta.append($('<div>', { 'class': 'ocr-fact-meta' }).text(
peopleBit + ' · ' + (job.status || '?') +
peopleBit + ' · ' + (job.status || '?') +
(job.status === 'processing' ? (' ' + (job.progress || 0) + '%') : '') +
(job.status === 'processing' ? (' ' + (job.progress || 0) + '%') : '') +
' · by ' + (job.uploader || '?')
' · by ' + (job.uploader || '?') + mineBit +
' · ' + timeAgo(job.created_at)
));
));
if (job.apply_progress && job.apply_progress.length) {
$meta.append($('<div>', { 'class': 'ocr-fact-meta' }).text(
'Partial apply: ' + job.apply_progress.length + ' page(s) already saved — Open and Apply to resume'
));
}
var $open = $('<button>', { type: 'button', text: 'Open' });
var $open = $('<button>', { type: 'button', text: 'Open' });
$meta.append($open);
$meta.append($open);
Line 2,520: Line 3,214:
});
});
}
}
$filters.find('input').on('change', loadQueue);
$clearReady.on('click', function () {
if (!cfg.canApply) {
return;
}
var n = Number($clearReady.data('readyCount') || 0);
var mineOnly = !!$clearReady.data('mineOnly');
if (!n) {
$clearStatus.text('No ready jobs to clear.');
return;
}
var msg = mineOnly
? ('Discard your ' + n + ' ready OCR job(s)? This cannot be undone.')
: ('Discard all ' + n + ' ready OCR job(s)? This cannot be undone.');
if (!window.confirm(msg)) {
return;
}
$clearReady.prop('disabled', true);
$clearStatus.text('Clearing…');
ocrPost({
op: 'clearqueue',
payload: JSON.stringify({
statuses: 'ready',
mine_only: mineOnly,
limit: 500
})
}).then(function (resp) {
var cleared = (resp && resp.cleared && resp.cleared.discarded) || 0;
$clearStatus.text('Discarded ' + cleared + ' job(s).');
$host2.empty();
loadQueue();
}).catch(function (err) {
$clearStatus.text(String(err && err.error && err.error.info || err));
}).always(function () {
$clearReady.prop('disabled', false);
});
});
loadQueue();
loadQueue();
}
}
Line 2,526: Line 3,257:
});
});
});
});
(function rbLiveTeammates() {
var $rails = $('.mw-parser-output .soldier-related[data-current-title]');
if (!$rails.length || !mw.Api) {
return;
}
function titleToCaption(title) {
var m = String(title || '').match(/^([^,]+),\s*(.+?)\s*\(/);
if (m) {
return $.trim(m[2] + ' ' + m[1]);
}
return String(title || '');
}
function wikiLink(title, caption) {
return $('<a></a>')
.attr('href', mw.util.getUrl(title))
.attr('title', title)
.text(caption || titleToCaption(title));
}
function fetchCategoryMembers(catTitle) {
var titles = [];
function next(cont) {
var params = {
action: 'query',
list: 'categorymembers',
cmtitle: catTitle,
cmlimit: 'max',
cmnamespace: 0,
cmtype: 'page'
};
if (cont) {
params.cmcontinue = cont;
}
return new mw.Api().get(params).then(function (data) {
var members = (data.query && data.query.categorymembers) || [];
members.forEach(function (m) {
if (m.title && !rbIsMergedDuplicateTitle(m.title)) {
titles.push(m.title);
}
});
var more = data.continue && data.continue.cmcontinue;
if (more) {
return next(more);
}
return titles;
});
}
return next(null);
}
$rails.each(function () {
var $rail = $(this);
if ($rail.data('rbLiveTeammates')) {
return;
}
$rail.data('rbLiveTeammates', 1);
var current = String($rail.attr('data-current-title') || mw.config.get('wgPageName') || '')
.replace(/_/g, ' ');
$rail.find('.soldier-related-block[data-team-cat]').each(function () {
var $block = $(this);
var cat = String($block.attr('data-team-cat') || '');
if (!/^Category:Team /.test(cat)) {
return;
}
var $links = $block.find('.soldier-related-links-live').first();
if (!$links.length) {
return;
}
$links.text('Loading teammates…');
fetchCategoryMembers(cat).done(function (titles) {
var others = titles.filter(function (t) {
return t.replace(/_/g, ' ') !== current;
});
others.sort(function (a, b) {
return titleToCaption(a).localeCompare(titleToCaption(b));
});
$links.empty();
if (!others.length) {
$links.append(document.createTextNode('No other teammates listed yet · '));
$links.append(wikiLink(cat, 'full roster'));
return;
}
var max = 24;
others.slice(0, max).forEach(function (title, i) {
if (i > 0) {
$links.append(document.createTextNode(' · '));
}
$links.append(wikiLink(title, titleToCaption(title)));
});
$links.append(document.createTextNode(' · '));
$links.append(wikiLink(cat, others.length > max ? 'full roster (' + others.length + ')' : 'full roster'));
}).fail(function () {
$links.empty().append(wikiLink(cat, 'Browse team roster'));
});
});
});
}());
$(function rbCampManualUpload() {
var $slot = $('.mw-parser-output .camp-manual-upload-slot').first();
if (!$slot.length || $slot.data('rbCampManualUpload')) {
return;
}
$slot.data('rbCampManualUpload', 1);
var LIBRARY = 'Training materials/Library';
var PLACEHOLDER = "''No manuals listed yet. Upload a PDF above and it will appear here.''";
var DOC_EXT = /\.(pdf|docx?|txt|rtf|odt|tiff?|jpe?g|png|gif|webp)$/i;
var pageTitle = mw.config.get('wgPageName');
var loggedIn = !!mw.config.get('wgUserName');
var uploadUrl = mw.util.getUrl('Special:Upload');
$slot.empty();
var $box = $('<div>', { 'class': 'camp-manual-upload-box soldier-upload-body' });
$box.append($('<p>', { 'class': 'soldier-contrib-help' }).append(
document.createTextNode('Upload a manual or related Camp Ritchie document (PDF preferred). Also '),
$('<a>', { href: uploadUrl, text: 'Special:Upload' }),
document.createTextNode('.')
));
if (!loggedIn) {
$box.append($('<p>', { 'class': 'soldier-upload-login' }).html(
'<a href="' + mw.util.getUrl('Special:UserLogin', { returnto: pageTitle }) + '">Log in</a> to upload files.'
));
$slot.append($box);
return;
}
var $form = $('<form>', { 'class': 'soldier-upload-form camp-manual-upload-form' });
var $file = $('<input>', {
type: 'file',
name: 'campManualFile',
'class': 'soldier-upload-file',
accept: '.pdf,.doc,.docx,.txt,.rtf,.odt,.tif,.tiff,.jpg,.jpeg,.png'
});
var $label = $('<label>').append(
document.createTextNode('Display title (optional)'),
$('<input>', {
type: 'text',
name: 'campManualTitle',
'class': 'camp-manual-title',
placeholder: 'e.g. Interrogation manual, 1943',
maxlength: 200
})
);
var $note = $('<label>').append(
document.createTextNode('Short note / source (optional)'),
$('<input>', {
type: 'text',
name: 'campManualNote',
'class': 'camp-manual-note',
placeholder: 'e.g. NARA / museum scan',
maxlength: 300
})
);
var $status = $('<p>', { 'class': 'soldier-upload-status', text: '' });
var $progress = $('<div>', { 'class': 'camp-manual-progress', hidden: true });
var $progressFill = $('<div>', { 'class': 'camp-manual-progress-fill' });
var $progressBar = $('<div>', {
'class': 'camp-manual-progress-bar',
role: 'progressbar',
'aria-valuemin': 0,
'aria-valuemax': 100,
'aria-valuenow': 0
}).append($progressFill);
var $progressLabel = $('<p>', { 'class': 'camp-manual-progress-label', text: '' });
$progress.append($progressBar, $progressLabel);
var $submit = $('<button>', { type: 'submit', text: 'Upload to library' });
$form.append($file, $label, $note, $submit, $progress, $status);
$box.append($form);
$slot.append($box);
function setStatus(msg, isError) {
$status.text(msg).css('color', isError ? '#ba0000' : '#202122');
}
function formatBytes(n) {
if (n >= 1024 * 1024) {
return (n / (1024 * 1024)).toFixed(1) + ' MB';
}
if (n >= 1024) {
return Math.round(n / 1024) + ' KB';
}
return n + ' B';
}
function setProgress(loaded, total) {
$progress.prop('hidden', false);
if (!total) {
$progress.addClass('is-indeterminate');
$progressBar.attr('aria-valuenow', 0);
$progressFill.css('width', '30%');
$progressLabel.text('Uploading…');
return;
}
$progress.removeClass('is-indeterminate');
var pct = Math.max(0, Math.min(100, Math.round((loaded / total) * 100)));
$progressBar.attr('aria-valuenow', pct);
$progressFill.css('width', pct + '%');
$progressLabel.text(pct + '% · ' + formatBytes(loaded) + ' of ' + formatBytes(total));
}
function resetProgress() {
$progress.prop('hidden', true).removeClass('is-indeterminate');
$progressFill.css('width', '0%');
$progressBar.attr('aria-valuenow', 0);
$progressLabel.text('');
}
function escapePipe(s) {
return String(s || '').replace(/\|/g, '–').replace(/\[\[/g, '').replace(/\]\]/g, '');
}
function buildBullet(filename, displayTitle, note) {
var label = escapePipe(displayTitle || filename.replace(/_/g, ' '));
var line = '* [[Media:' + filename + '|' + label + ']]';
if (note) {
line += ' — ' + escapePipe(note);
}
return line;
}
function appendToLibrary(api, filename, displayTitle, note) {
var bullet = buildBullet(filename, displayTitle, note);
return api.get({
action: 'query',
prop: 'revisions|info',
titles: LIBRARY,
rvprop: 'content|timestamp',
rvslots: 'main',
curtimestamp: 1,
formatversion: 2,
maxage: 0,
smaxage: 0
}).then(function (data) {
var page = data.query && data.query.pages && data.query.pages[0];
var rev = page && page.revisions && page.revisions[0];
var text = (rev && rev.slots && rev.slots.main && rev.slots.main.content) || '';
var basets = rev && rev.timestamp;
var startts = data.curtimestamp;
var next;
var trimmed = $.trim(text);
var already = trimmed.indexOf('[[Media:' + filename + '|') !== -1 ||
trimmed.indexOf('[[File:' + filename + '|') !== -1;
if (already) {
return purgeLibraryPages(api).then(function () {
return { edit: { result: 'Success', nochange: true } };
});
}
if (!trimmed || trimmed.indexOf(PLACEHOLDER) !== -1) {
next = bullet + '\n';
} else {
next = trimmed + '\n' + bullet + '\n';
}
var params = {
action: 'edit',
title: LIBRARY,
text: next,
summary: 'Add training material: ' + filename,
format: 'json'
};
if (basets) {
params.basetimestamp = basets;
}
if (startts) {
params.starttimestamp = startts;
}
return api.postWithToken('csrf', params).then(function (editData) {
if (editData.edit && editData.edit.result === 'Success') {
return purgeLibraryPages(api).then(function () {
return editData;
});
}
if (editData.error && editData.error.code === 'editconflict') {
return appendToLibrary(api, filename, displayTitle, note);
}
throw new Error((editData.error && editData.error.info) || 'Could not update library list');
});
});
}
function failMessage(code, data) {
if (data && data.error && data.error.info) {
return data.error.info;
}
if (data && data.exception && data.exception.message) {
return data.exception.message;
}
if (code && typeof code === 'object' && code.message) {
return code.message;
}
if (typeof code === 'string' && code && code !== 'http' && code !== 'error' && code !== 'timeout') {
return code;
}
if (data && data.xhr && data.xhr.status) {
return 'Upload failed (HTTP ' + data.xhr.status + '). Try again, or use Special:Upload.';
}
return 'Upload failed (connection dropped or timed out). Try again, or use Special:Upload.';
}
function purgeLibraryPages(api) {
return api.postWithToken('csrf', {
action: 'purge',
titles: 'Training materials|' + LIBRARY,
forcelinkupdate: 1,
format: 'json'
});
}
function readLibraryText(api) {
return api.get({
action: 'query',
prop: 'revisions|info',
titles: LIBRARY,
rvprop: 'content|timestamp',
rvslots: 'main',
curtimestamp: 1,
formatversion: 2,
maxage: 0,
smaxage: 0
}).then(function (data) {
var page = data.query && data.query.pages && data.query.pages[0];
var rev = page && page.revisions && page.revisions[0];
return {
text: (rev && rev.slots && rev.slots.main && rev.slots.main.content) || '',
basetimestamp: rev && rev.timestamp,
starttimestamp: data.curtimestamp
};
});
}
function writeLibraryText(api, next, summary, basets, startts) {
var params = {
action: 'edit',
title: LIBRARY,
text: next,
summary: summary,
format: 'json'
};
if (basets) {
params.basetimestamp = basets;
}
if (startts) {
params.starttimestamp = startts;
}
return api.postWithToken('csrf', params).then(function (editData) {
if (editData.edit && editData.edit.result === 'Success') {
return purgeLibraryPages(api).then(function () {
return editData;
});
}
if (editData.error && editData.error.code === 'editconflict') {
throw new Error('editconflict');
}
throw new Error((editData.error && editData.error.info) || 'Could not update library list');
});
}
function removeFromLibrary(api, filename) {
return readLibraryText(api).then(function (cur) {
var lines = String(cur.text || '').split(/\r?\n/);
var nextLines = lines.filter(function (line) {
return line.indexOf('[[Media:' + filename + '|') === -1 &&
line.indexOf('[[File:' + filename + '|') === -1 &&
line.indexOf(filename) === -1;
});
var next = $.trim(nextLines.join('\n'));
if (!next) {
next = PLACEHOLDER;
} else {
next = next + '\n';
}
return writeLibraryText(
api,
next,
'Remove from Training materials library: ' + filename,
cur.basetimestamp,
cur.starttimestamp
).then(null, function (err) {
if (String(err && err.message || err) === 'editconflict') {
return removeFromLibrary(api, filename);
}
throw err;
});
});
}
function deleteLibraryFile(api, filename) {
return api.postWithToken('csrf', {
action: 'delete',
title: 'File:' + filename,
reason: 'Removed from Training materials library',
format: 'json'
}).then(function (data) {
if (data.error) {
throw new Error(data.error.info || 'Could not delete file');
}
return removeFromLibrary(api, filename);
});
}
function filenameFromHref(href, titleAttr) {
if (titleAttr && /\.[a-z0-9]{2,5}$/i.test(titleAttr)) {
return String(titleAttr).replace(/ /g, '_');
}
if (!href) {
return '';
}
try {
href = decodeURIComponent(href);
} catch (e) { /* ignore */ }
var m = href.match(/\/wiki\/(?:File|Media):([^?#]+)/i) ||
href.match(/[?&]title=(?:File|Media):([^&#]+)/i) ||
href.match(/\/images\/(?:thumb\/)?[^/]+\/[^/]+\/([^/?#]+)$/i);
if (!m) {
return '';
}
return m[1].replace(/_/g, ' ').trim().replace(/ /g, '_');
}
function enhanceLibraryManage() {
if (!mw.config.get('wgUserName')) {
return;
}
var $heading = $('.mw-parser-output .mw-heading, .mw-parser-output h2').filter(function () {
return /^Library\b/i.test($(this).text().replace(/\s*\[.*$/, '').trim());
}).first();
if (!$heading.length) {
return;
}
var $list = $heading.nextAll('ul').first();
if (!$list.length) {
$list = $heading.parent().nextAll('ul').first();
}
if (!$list.length || $list.data('rbCampManualManage')) {
return;
}
$list.data('rbCampManualManage', 1);
var groups = mw.config.get('wgUserGroups') || [];
var canDeleteFile = groups.indexOf('sysop') !== -1;
var api = new mw.Api();
$list.children('li').each(function () {
var $li = $(this);
if ($li.find('.camp-manual-lib-actions').length) {
return;
}
var $a = $li.find('a.internal, a[href*="/images/"], a[href*="File:"], a[href*="Media:"]').first();
if (!$a.length) {
$a = $li.find('a[href]').first();
}
var filename = filenameFromHref($a.attr('href'), $a.attr('title'));
if (!filename) {
return;
}
var $actions = $('<span>', { 'class': 'camp-manual-lib-actions' });
var $remove = $('<button>', {
type: 'button',
'class': 'camp-manual-lib-remove',
text: 'Remove'
}).attr('title', 'Remove from this list (file stays on the wiki)');
$actions.append($remove);
if (canDeleteFile) {
$actions.append($('<button>', {
type: 'button',
'class': 'camp-manual-lib-delete',
text: 'Delete file'
}).attr('title', 'Delete the file from the wiki (administrators only)'));
}
$li.append($actions);
$remove.on('click', function () {
if (!window.confirm('Remove “' + filename.replace(/_/g, ' ') + '” from the Library list?\n(The file will remain on the wiki.)')) {
return;
}
$actions.find('button').prop('disabled', true);
removeFromLibrary(api, filename).done(function () {
window.location.reload();
}).fail(function (code, data) {
window.alert(failMessage(code, data));
$actions.find('button').prop('disabled', false);
});
});
$actions.on('click', '.camp-manual-lib-delete', function () {
if (!window.confirm('Permanently delete file “' + filename.replace(/_/g, ' ') + '” from the wiki?')) {
return;
}
$actions.find('button').prop('disabled', true);
deleteLibraryFile(api, filename).done(function () {
window.location.reload();
}).fail(function (code, data) {
window.alert(failMessage(code, data));
$actions.find('button').prop('disabled', false);
});
});
});
}
enhanceLibraryManage();
$form.on('submit', function (evt) {
evt.preventDefault();
var file = $file[0].files[0];
if (!file) {
setStatus('Choose a file first.', true);
return;
}
if (!DOC_EXT.test(file.name)) {
setStatus('File type not allowed (PDF, Office docs, images, TIFF).', true);
return;
}
var maxBytes = 120 * 1024 * 1024;
if (file.size > maxBytes) {
setStatus('File is too large (max about 120 MB). Split the PDF or compress it, then try again.', true);
return;
}
var displayTitle = $.trim($form.find('.camp-manual-title').val() || '');
var note = $.trim($form.find('.camp-manual-note').val() || '');
$submit.prop('disabled', true);
resetProgress();
setProgress(0, file.size);
setStatus('Uploading…');
var api = new mw.Api({ ajax: { timeout: 0 } });
var fileText = 'Camp Ritchie / MITC training material.';
var uploadedName = null;
var uploadParams = {
filename: file.name,
comment: 'Uploaded to Training materials library',
text: fileText,
ignorewarnings: true
};
function doUpload() {
/* Multipart + XHR progress (api.upload alone often has no progress events). */
return api.postWithToken('csrf', $.extend({
action: 'upload',
file: file,
format: 'json'
}, uploadParams), {
contentType: 'multipart/form-data',
timeout: 0,
xhr: function () {
var xhr = $.ajaxSettings.xhr();
if (xhr.upload) {
xhr.upload.addEventListener('progress', function (ev) {
if (ev.lengthComputable) {
setProgress(ev.loaded, ev.total);
}
});
}
return xhr;
}
});
}
doUpload().then(function (data) {
uploadedName = data.upload && data.upload.filename;
if (!uploadedName) {
throw new Error((data.error && data.error.info) || 'Upload failed');
}
setProgress(file.size, file.size);
$progress.addClass('is-indeterminate');
$progressLabel.text('Finishing…');
setStatus('Adding to Library list…');
return appendToLibrary(api, uploadedName, displayTitle, note);
}).done(function () {
$progress.removeClass('is-indeterminate');
$progressLabel.text('100% · done');
setStatus('Done. Reloading…');
window.location.reload();
}).fail(function (code, data) {
var err = failMessage(code, data);
if (uploadedName) {
err = 'File uploaded as “' + uploadedName + '”, but the Library list was not updated: ' + err +
' Refresh this page; if it is missing from the list, say so and we can add it.';
}
setStatus(String(err), true);
resetProgress();
$submit.prop('disabled', false);
});
});
});
(function rbBooksFilters() {
var $host = $('.books-filters-host').first();
var $slides = $('.mw-parser-output .notable-slides').first();
if (!$host.length || !$slides.length) return;
if (!$slides.find('.notable-slide-card[class*="book-kind-"]').length) return;
if ($host.find('.books-filters').length) return;
var filters = [
{ id: 'all', label: 'All' },
{ id: 'memoir', label: 'Memoirs & letters' },
{ id: 'history', label: 'History' },
{ id: 'novel', label: 'Novels' },
{ id: 'other', label: 'Other' }
];
var $bar = $('<div class="books-filters" role="group" aria-label="Filter books"></div>');
filters.forEach(function (f, i) {
var $btn = $('<button type="button" class="books-filter"></button>');
$btn.attr('data-filter', f.id).text(f.label);
if (i === 0) $btn.addClass('is-active');
$bar.append($btn);
});
$host.empty().append($bar);
$bar.on('click', '.books-filter', function () {
var kind = $(this).attr('data-filter');
$bar.find('.books-filter').removeClass('is-active');
$(this).addClass('is-active');
$slides.find('.notable-slide-card').each(function () {
var $c = $(this);
var cls = $c.attr('class') || '';
var m = cls.match(/book-kind-([a-z]+)/);
var k = m ? m[1] : 'other';
var show = kind === 'all' || k === kind;
$c.toggleClass('is-book-hidden', !show);
});
});
}());
/* Related-media wrap removed: story/interview cues already sit under the lead
* with their own labels (Full story, etc.). Wrapping them added a stray
* Related media heading near the top of the article. */