MediaWiki:Common.js: Difference between revisions
OCR review UX: person cards, apply preview, Advanced |
Add Internet Archive click-to-play embeds for interview watchlist |
||
| (36 intermediate revisions by the same user not shown) | |||
| Line 7: | Line 7: | ||
* with live title suggestions (same idea as the header search box). | * with live title suggestions (same idea as the header search box). | ||
* Reorder Special:Search hits so title matches and site pages rank above research-notes noise. | * Reorder Special:Search hits so title matches and site pages rank above research-notes noise. | ||
* Hide retired “(merged duplicate)” roster pages from find/search suggestions and results. | |||
* Fill soldier Teammates rails from live Category:Team … membership (no roster-module rebuild). | |||
* Inject site hub links into the Minerva hamburger (Sidebar is not used by Minerva 1.43). | |||
*/ | */ | ||
function rbIsMergedDuplicateTitle(title) { | |||
return /\(merged duplicate\)\s*$/i.test(String(title || '')); | |||
} | |||
$(function () { | $(function () { | ||
(function injectMinervaSiteMenu() { | |||
if (mw.config.get('skin') !== 'minerva') { | |||
return; | |||
} | |||
var $drawer = $('#mw-mf-page-left'); | |||
var $after = $('#p-navigation'); | |||
if (!$drawer.length || !$after.length || $drawer.data('rbSiteMenu')) { | |||
return; | |||
} | |||
$drawer.data('rbSiteMenu', 1); | |||
var links = [ | |||
{ title: 'Contents', label: 'Contents', icon: 'listBullet' }, | |||
{ title: 'Start here', label: 'Start here', icon: 'lightbulb' }, | |||
{ title: 'Introduction', label: 'Introduction', icon: 'book' }, | |||
{ title: 'The Stories', label: 'Stories', icon: 'newspaper' }, | |||
{ title: 'Category:Classes', label: 'Classes', icon: 'userGroup' }, | |||
{ title: 'Photos', label: 'Photos', icon: 'imageGallery' }, | |||
{ title: 'Training materials', label: 'Training materials', icon: 'articles' }, | |||
{ title: 'Contribute', label: 'Contribute', icon: 'edit' }, | |||
{ title: 'Contact Us', label: 'Contact Us', icon: 'speechBubbles' }, | |||
{ title: 'About', label: 'About', icon: 'infoFilled' } | |||
]; | |||
var $ul = $('<ul id="p-ritchie" class="toggle-list__list"></ul>'); | |||
links.forEach(function (item) { | |||
var name = 'rb-' + String(item.title).toLowerCase().replace(/[^a-z0-9]+/g, '-'); | |||
var $a = $('<a class="toggle-list-item__anchor"></a>') | |||
.addClass('menu__item--' + name) | |||
.attr('href', mw.util.getUrl(item.title)); | |||
$a.append($('<span class="minerva-icon"></span>').addClass('minerva-icon--' + item.icon)); | |||
$a.append($('<span class="toggle-list-item__label"></span>').text(item.label)); | |||
$ul.append($('<li class="toggle-list-item"></li>').append($a)); | |||
}); | |||
$after.after($ul); | |||
}()); | |||
(function buildHomeFind() { | (function buildHomeFind() { | ||
var $slot = $('.mw-parser-output .home-find-slot').first(); | var $slot = $('.mw-parser-output .home-find-slot').first(); | ||
| Line 111: | Line 153: | ||
function rankTitles(query, hits) { | function rankTitles(query, hits) { | ||
var q = String(query || '').trim().toLowerCase(); | var q = String(query || '').trim().toLowerCase(); | ||
var scored = (hits || []).map(function (h) { | var scored = (hits || []).filter(function (h) { | ||
return !rbIsMergedDuplicateTitle(h.title || ''); | |||
}).map(function (h) { | |||
var t = h.title || ''; | var t = h.title || ''; | ||
var low = t.toLowerCase(); | var low = t.toLowerCase(); | ||
| Line 284: | Line 328: | ||
function resort(siteTitles) { | function resort(siteTitles) { | ||
var items = $list.children('li').get(); | var items = $list.children('li').get(); | ||
items.forEach(function (el) { | |||
if (rbIsMergedDuplicateTitle(titleFromItem(el))) { | |||
$(el).remove(); | |||
} | |||
}); | |||
items = $list.children('li').get(); | |||
if (items.length < 2) { | if (items.length < 2) { | ||
return; | return; | ||
| Line 327: | Line 377: | ||
resort(siteTitles); | resort(siteTitles); | ||
}); | }); | ||
}()); | |||
(function emptySearchHelp() { | |||
if (mw.config.get('wgCanonicalSpecialPageName') !== 'Search') { | |||
return; | |||
} | |||
var params = new URLSearchParams(window.location.search); | |||
var raw = String(params.get('search') || '').trim(); | |||
if (!raw) { | |||
return; | |||
} | |||
var $results = $('.mw-search-results').first(); | |||
var noneFound = $('.mw-search-nonefound').length > 0 | |||
|| ($('.searchresults').length && !$results.length) | |||
|| ($results.length && $results.children('li').length === 0); | |||
if (!noneFound) { | |||
return; | |||
} | |||
if ($('.rb-search-empty-help').length) { | |||
return; | |||
} | |||
var contactUrl = mw.util.getUrl('Contact Us'); | |||
var startUrl = mw.util.getUrl('Start here') + '#If_the_name_is_not_found'; | |||
var $box = $('<div class="rb-search-empty-help" role="note"></div>'); | |||
$box.append($('<p></p>').append( | |||
$('<strong></strong>').text('No soldier page matched “' + raw + '”.') | |||
)); | |||
$box.append($('<ul></ul>').append( | |||
$('<li></li>').text('Try alternate spellings, anglicized names, or maiden vs married surnames.'), | |||
$('<li></li>').text('Try the ASN with or without leading zeros.'), | |||
$('<li></li>').text('They may be missing from the Classes 1–31 roster import or not yet matched.') | |||
)); | |||
$box.append($('<p></p>').append( | |||
$('<a></a>').attr('href', startUrl).text('If the name is not found'), | |||
document.createTextNode(' · '), | |||
$('<a></a>').attr('href', contactUrl).text('Contact Us') | |||
)); | |||
var $host = $('.mw-search-nonefound').first(); | |||
if ($host.length) { | |||
$host.after($box); | |||
} else if ($('.searchresults').length) { | |||
$('.searchresults').first().prepend($box); | |||
} else { | |||
$('#mw-content-text').prepend($box); | |||
} | |||
}()); | }()); | ||
| Line 384: | Line 479: | ||
}()); | }()); | ||
(function | (function buildYouTubeWatchlist() { | ||
// | var YT_ID = /^[A-Za-z0-9_-]{11}$/; | ||
function splitSoldiers(raw) { | |||
return String(raw || '').split(';').map(function (s) { | |||
return $.trim(s); | |||
}).filter(Boolean); | |||
} | } | ||
function | function overlap(a, b) { | ||
var | 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>') | ||
var $ | .attr({ | ||
if ($ | src: src, | ||
$ | title: title || 'YouTube video', | ||
loading: 'lazy' | |||
}); | |||
$slot.empty().append($iframe).addClass('yt-embed-playing'); | |||
} | |||
$('.mw-parser-output .yt-embed[data-youtube-id]').each(function () { | |||
var $slot = $(this); | |||
if ($slot.data('rbYtEmbed')) { | |||
return; | |||
} | |||
var id = String($slot.attr('data-youtube-id') || ''); | |||
if (!YT_ID.test(id)) { | |||
return; | |||
} | |||
$slot.data('rbYtEmbed', 1); | |||
var title = String($slot.attr('data-title') || 'YouTube video'); | |||
var $fallback = $slot.find('.yt-embed-fallback').first(); | |||
var $btn = $('<button type="button" class="yt-embed-play"></button>') | |||
.attr('aria-label', 'Play ' + title) | |||
.append($('<span class="yt-embed-play-icon" aria-hidden="true"></span>')) | |||
.append($('<span class="yt-embed-play-text"></span>').text('Play video')); | |||
var $thumb = $('<img class="yt-embed-thumb" alt="">') | |||
.attr({ | |||
src: 'https://i.ytimg.com/vi/' + encodeURIComponent(id) + '/hqdefault.jpg', | |||
alt: title | |||
}); | |||
var $poster = $('<div class="yt-embed-poster"></div>').append($thumb).append($btn); | |||
if ($fallback.length) { | |||
$fallback.before($poster); | |||
$fallback.hide(); | |||
} else { | } else { | ||
$slot.prepend($poster); | |||
} | |||
$poster.on('click', function (ev) { | |||
ev.preventDefault(); | |||
playEmbed($slot, id, title); | |||
}); | |||
} | }); | ||
function interviewCardRef($el) { | |||
var yt = String($el.attr('data-youtube-id') || ''); | |||
if (yt) { | |||
return { key: 'yt:' + yt, href: '#yt-' + yt }; | |||
} | |||
var sc = String($el.attr('data-soundcloud-track') || ''); | |||
if (sc) { | |||
return { key: 'sc:' + sc, href: '#sc-' + sc }; | |||
} | } | ||
return null; | |||
} | } | ||
var $cards = $('.mw-parser-output .yt-interview').filter(function () { | |||
var $ | return interviewCardRef($(this)) !== null; | ||
if ($ | }); | ||
$ | $cards.each(function (idx) { | ||
var $ | var $card = $(this); | ||
if ($ | if ($card.data('rbYtNext')) { | ||
return; | |||
} | |||
$card.data('rbYtNext', 1); | |||
var $slot = $card.find('.yt-interview-next').first(); | |||
if (!$slot.length) { | |||
return; | |||
} | } | ||
var mine = splitSoldiers($card.attr('data-soldiers')); | |||
var selfRef = interviewCardRef($card); | |||
var picks = []; | |||
var seen = {}; | |||
function addCard($other, why) { | |||
var ref = interviewCardRef($other); | |||
if (!ref || !selfRef || seen[ref.key] || ref.key === selfRef.key) { | |||
return; | |||
} | } | ||
seen[ref.key] = 1; | |||
picks.push({ | |||
href: ref.href, | |||
heading: String($other.attr('data-heading') || $other.find('h3, h2').first().text() || 'Interview'), | |||
short: String($other.attr('data-short') || ''), | |||
why: why | |||
}); | |||
} | } | ||
$cards.each(function (j) { | |||
if (j === idx || picks.length >= 3) { | |||
return; | |||
} | |||
var $other = $($cards.get(j)); | |||
if (overlap(mine, splitSoldiers($other.attr('data-soldiers')))) { | |||
addCard($other, 'same soldier'); | |||
} | |||
}); | |||
if (idx + 1 < $cards.length) { | |||
addCard($($cards.get(idx + 1)), 'next'); | |||
} | |||
if (!picks.length) { | |||
return; | |||
} | |||
var $list = $('<ul class="yt-interview-next-list"></ul>'); | |||
picks.slice(0, 3).forEach(function (item) { | |||
var label = item.short ? (item.heading + ' — ' + item.short) : item.heading; | |||
var $a = $('<a></a>').attr('href', item.href).text(label); | |||
var $li = $('<li></li>').append($a); | |||
} | if (item.why === 'same soldier') { | ||
$li.append($('<span class="yt-interview-next-why"></span>').text(' (same person)')); | |||
} | |||
$list.append($li); | |||
}); | |||
$slot.empty() | |||
.append($('<div class="yt-interview-next-label"></div>').text('Watch next')) | |||
.append($list); | |||
}); | |||
}()); | }()); | ||
var | (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'); | |||
} | } | ||
var | |||
$('.mw-parser-output .sc-embed[data-soundcloud-track]').each(function () { | |||
var $slot = $(this); | |||
} | if ($slot.data('rbScEmbed')) { | ||
return; | |||
} | |||
var trackId = String($slot.attr('data-soundcloud-track') || ''); | |||
if (!SC_TRACK.test(trackId)) { | |||
return; | |||
} | |||
$slot.data('rbScEmbed', 1); | |||
var title = String($slot.attr('data-title') || 'SoundCloud audio'); | |||
var $fallback = $slot.find('.sc-embed-fallback').first(); | |||
var $btn = $('<button type="button" class="sc-embed-play"></button>') | |||
.attr('aria-label', 'Play ' + title) | |||
.append($('<span class="sc-embed-play-icon" aria-hidden="true"></span>')) | |||
.append($('<span class="sc-embed-play-text"></span>').text('Play audio')); | |||
var $poster = $('<div class="sc-embed-poster"></div>').append($btn); | |||
if ($fallback.length) { | |||
$fallback.before($poster); | |||
$fallback.hide(); | |||
} else { | |||
$slot.prepend($poster); | |||
} | |||
$poster.on('click', function (ev) { | |||
ev.preventDefault(); | |||
playSoundCloud($slot, trackId, title); | |||
}); | |||
}); | |||
}()); | |||
(function buildAviaryEmbeds() { | |||
var AV_MEDIA = /^[0-9]{4,12}$/; | |||
function playAviary($slot, mediaId, title) { | |||
var src = 'https://milken.aviaryplatform.com/embed/media/' + | |||
encodeURIComponent(mediaId) + | |||
'?embed=true&media_player=true'; | |||
var $iframe = $('<iframe class="aviary-embed-frame" allowfullscreen allow="autoplay; encrypted-media; fullscreen; picture-in-picture"></iframe>') | |||
.attr({ | |||
src: src, | |||
title: title || 'Aviary media', | |||
loading: 'lazy' | |||
}); | |||
$slot.empty().append($iframe).addClass('aviary-embed-playing'); | |||
} | } | ||
return | |||
$('.mw-parser-output .aviary-embed[data-aviary-media]').each(function () { | |||
var $slot = $(this); | |||
if ($slot.data('rbAviaryEmbed')) { | |||
return; | |||
} | |||
} | var mediaId = String($slot.attr('data-aviary-media') || ''); | ||
if (!AV_MEDIA.test(mediaId)) { | |||
return; | |||
} | |||
$slot.data('rbAviaryEmbed', 1); | |||
var title = String($slot.attr('data-title') || 'Aviary media'); | |||
var playLabel = String($slot.attr('data-play-label') || 'Play video'); | |||
var $fallback = $slot.find('.aviary-embed-fallback').first(); | |||
var $btn = $('<button type="button" class="aviary-embed-play"></button>') | |||
.attr('aria-label', playLabel + ' — ' + title) | |||
.append($('<span class="aviary-embed-play-icon" aria-hidden="true"></span>')) | |||
.append($('<span class="aviary-embed-play-text"></span>').text(playLabel)); | |||
var $poster = $('<div class="aviary-embed-poster"></div>').append($btn); | |||
if ($fallback.length) { | |||
$fallback.before($poster); | |||
$fallback.hide(); | |||
} else { | |||
$slot.prepend($poster); | |||
} | |||
$poster.on('click', function (ev) { | |||
ev.preventDefault(); | |||
playAviary($slot, mediaId, title); | |||
}); | |||
}); | |||
}()); | |||
(function buildInternetArchiveEmbeds() { | |||
// archive.org item identifiers (e.g. RobertA.Potash13january2012YiddishBookCenter) | |||
var IA_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{5,120}$/; | |||
function playInternetArchive($slot, itemId, title) { | |||
var src = 'https://archive.org/embed/' + encodeURIComponent(itemId); | |||
var $iframe = $('<iframe class="ia-embed-frame" allowfullscreen allow="autoplay; encrypted-media; fullscreen; picture-in-picture"></iframe>') | |||
.attr({ | |||
src: src, | |||
title: title || 'Internet Archive media', | |||
loading: 'lazy' | |||
}); | |||
$slot.empty().append($iframe).addClass('ia-embed-playing'); | |||
} | } | ||
if ( | |||
$('.mw-parser-output .ia-embed[data-archive-id]').each(function () { | |||
var $slot = $(this); | |||
if ($slot.data('rbIaEmbed')) { | |||
return; | |||
} | |||
var itemId = String($slot.attr('data-archive-id') || ''); | |||
if (!IA_ID.test(itemId)) { | |||
return; | |||
} | |||
$slot.data('rbIaEmbed', 1); | |||
var title = String($slot.attr('data-title') || 'Internet Archive media'); | |||
var playLabel = String($slot.attr('data-play-label') || 'Play interview'); | |||
var $fallback = $slot.find('.ia-embed-fallback').first(); | |||
var $btn = $('<button type="button" class="ia-embed-play"></button>') | |||
.attr('aria-label', playLabel + ' — ' + title) | |||
.append($('<span class="ia-embed-play-icon" aria-hidden="true"></span>')) | |||
.append($('<span class="ia-embed-play-text"></span>').text(playLabel)); | |||
var $poster = $('<div class="ia-embed-poster"></div>').append($btn); | |||
if ($fallback.length) { | |||
$fallback.before($poster); | |||
$fallback.hide(); | |||
} else { | |||
$slot.prepend($poster); | |||
} | |||
$poster.on('click', function (ev) { | |||
ev.preventDefault(); | |||
playInternetArchive($slot, itemId, title); | |||
}); | |||
}); | |||
}()); | |||
(function placeSoldierAwardsSection() { | |||
// Ribbon racks belong in the main column after Biography (like Wikipedia's | |||
// Awards section). Do not use a wiki == heading here — that stole the TOC. | |||
var $out = $('.mw-parser-output').first(); | |||
var $awards = $out.find('.soldier-awards-section').first(); | |||
if (!$out.length) { | |||
return; | |||
} | } | ||
function sectionAfter($h) { | |||
var | var $nodes = $h.nextUntil('h2'); | ||
if ($nodes.length) { | |||
return $nodes.last(); | |||
} | } | ||
return | return $h; | ||
} | } | ||
if ( | |||
if ($awards.length && !$awards.data('rbAwardsPlaced')) { | |||
$awards.data('rbAwardsPlaced', 1); | |||
var $bio = $out.find('h2').filter(function () { | |||
var t = $(this).find('.mw-headline').text() || $(this).text(); | |||
return /^(Biography|Life)\b/i.test($.trim(t)); | |||
}).first(); | |||
if ($bio.length) { | |||
sectionAfter($bio).after($awards); | |||
} else { | |||
var $before = $out.find('h2').filter(function () { | |||
var t = $(this).find('.mw-headline').text() || $(this).text(); | |||
return /^(Gallery|Research notes|Sources|Technical record)\b/i.test($.trim(t)); | |||
}).first(); | |||
if ($before.length) { | |||
$before.before($awards); | |||
} else { | |||
$out.append($awards); | |||
} | |||
} | |||
} | } | ||
// Class/team rail rides with awards (after Biography). | |||
var $related = $out.find('.soldier-related').first(); | |||
if ($related.length && !$related.data('rbRelatedPlaced')) { | |||
$related.data('rbRelatedPlaced', 1); | |||
var $awardsNow = $out.find('.soldier-awards-section').first(); | |||
if ($awardsNow.length) { | |||
$awardsNow.after($related); | |||
} else { | |||
var $bio2 = $out.find('h2').filter(function () { | |||
var t = $(this).find('.mw-headline').text() || $(this).text(); | |||
return /^(Biography|Life)\b/i.test($.trim(t)); | |||
}).first(); | |||
if ($bio2.length) { | |||
sectionAfter($bio2).after($related); | |||
} | |||
} | |||
} | } | ||
// Story cue belongs under the lead (above Biography). | |||
if ( | var $storyCue = $out.find('.soldier-story-cue').first(); | ||
if ($storyCue.length && !$storyCue.data('rbStoryCuePlaced')) { | |||
$storyCue.data('rbStoryCuePlaced', 1); | |||
var $leadCue = $out.find('.soldier-lead, .page-lead').first(); | |||
if ($leadCue.length) { | |||
$leadCue.after($storyCue); | |||
} | } | ||
} | |||
// Interview / film cue sits with the story cue under the lead. | |||
var | var $mediaCue = $out.find('.soldier-media-cue').first(); | ||
if ( | if ($mediaCue.length && !$mediaCue.data('rbMediaCuePlaced')) { | ||
$mediaCue.data('rbMediaCuePlaced', 1); | |||
var $afterStory = $out.find('.soldier-story-cue').first(); | |||
if ($afterStory.length) { | |||
$afterStory.after($mediaCue); | |||
} else { | |||
var $leadMedia = $out.find('.soldier-lead, .page-lead').first(); | |||
if ($leadMedia.length) { | |||
$leadMedia.after($mediaCue); | |||
} | } | ||
} | |||
} | |||
} | } | ||
// Contents box belongs after the lead on soldier/profile pages only. | |||
// Other pages (e.g. Site changelog) keep MediaWiki's default TOC placement. | |||
var $toc = $out.find('#toc, .toc, .mw-table-of-contents').first(); | |||
if ($toc.length && !$toc.data('rbTocPlaced')) { | |||
$toc.data('rbTocPlaced', 1); | |||
var $lead = $out.find('.soldier-lead, .page-lead, .home-lead').first(); | |||
if ($lead.length) { | |||
$lead.after($toc); | |||
} else { | |||
var $ibox = $out.find('table.soldier-infobox, table.infobox').first(); | |||
if ($ibox.length) { | |||
$ibox.after($toc); | |||
} | |||
} | |||
} | } | ||
var | }()); | ||
var | |||
if (! | (function moveSoldierFooterCues() { | ||
var $out = $('.mw-parser-output').first(); | |||
if (!$out.length) { | |||
return; | |||
} | |||
var $notes = $out.find('.research-notes-disclaimer').first(); | |||
var $sources = $out.find('.source-confidence-cue').first(); | |||
if (!$notes.length && !$sources.length) { | |||
return; | |||
} | |||
var $footer = $out.find('.soldier-page-footer-notes').first(); | |||
if (!$footer.length) { | |||
$footer = $('<div class="soldier-page-footer-notes"></div>'); | |||
$out.append($footer); | |||
} | |||
if ($notes.length) { | |||
$footer.append($notes); | |||
} | } | ||
if ($sources.length) { | |||
$footer.append($sources); | |||
} | |||
}()); | |||
var NAME_PARTICLES = { | |||
von: 1, van: 1, de: 1, der: 1, den: 1, da: 1, di: 1, du: 1, | |||
la: 1, le: 1, del: 1, della: 1, ter: 1, ten: 1, zu: 1, zum: 1, bin: 1 | |||
}; | |||
var NAME_SUFFIX = { | |||
jr: 'Jr', 'jr.': 'Jr.', sr: 'Sr', 'sr.': 'Sr.', | |||
esq: 'Esq', 'esq.': 'Esq.', ii: 'II', iii: 'III', iv: 'IV' | |||
}; | |||
function lettersOnly(s) { | |||
return String(s || '').replace(/[^A-Za-z]/g, ''); | |||
} | |||
function isAllCaps(s) { | |||
var letters = lettersOnly(s); | |||
return letters.length >= 1 && letters === letters.toUpperCase(); | |||
} | } | ||
function isAllLower(s) { | |||
var letters = lettersOnly(s); | |||
return letters.length >= 2 && letters === letters.toLowerCase(); | |||
} | } | ||
function titleCaseWord(s) { | |||
s = String(s || '').toLowerCase(); | |||
return s ? s.charAt(0).toUpperCase() + s.slice(1) : s; | |||
} | } | ||
function prettyCapsWord(word) { | |||
var trail = ''; | |||
while (word && /[.,]$/.test(word)) { | |||
trail = word.slice(-1) + trail; | |||
word = word.slice(0, -1); | |||
} | |||
var up = lettersOnly(word).toUpperCase(); | |||
if (up.length >= 5 && up.slice(0, 2) === 'MC') { | |||
return 'Mc' + titleCaseWord(up.slice(2)) + trail; | |||
} | } | ||
if (up.length >= 7 && up.slice(0, 3) === 'MAC') { | |||
return 'Mac' + titleCaseWord(up.slice(3)) + trail; | |||
return; | |||
} | } | ||
return titleCaseWord(up) + trail; | |||
} | |||
function prettyNameToken(word, leading) { | |||
word = $.trim(word); | |||
} | if (!word) { | ||
return word; | |||
} | |||
if (/\d/.test(word)) { | |||
if ( | 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 | var bare = lettersOnly(word); | ||
return | if (bare.length === 1) { | ||
} | return bare.toUpperCase() + word.replace(/[A-Za-z]/, ''); | ||
} | |||
return | 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 | return canon + '.'; | ||
} | |||
return canon; | |||
} | |||
} | if (NAME_PARTICLES[lowBare] && /^[A-Za-z]+$/.test(word) && (isAllCaps(word) || isAllLower(word))) { | ||
return leading ? titleCaseWord(lowBare) : lowBare; | |||
var | } | ||
if (isAllCaps(word) && bare.length >= 2 && bare.length <= 3 && !/[AEIOUY]/i.test(bare)) { | |||
return word; | |||
} | |||
if (isAllCaps(word) || isAllLower(word)) { | |||
} | return prettyCapsWord(word); | ||
} | |||
return | return word; | ||
} | |||
return | |||
} | |||
return | |||
} | |||
return | |||
} | } | ||
function prettyNamePhrase(text, capLead) { | |||
function | text = $.trim(text || '').replace(/\s*;\s*/g, ' '); | ||
text = $.trim(text || '') | if (!text) { | ||
return ''; | |||
} | |||
return | return $.map(text.split(/\s*,\s*/), function (clause, clauseI) { | ||
clause = $.trim(clause); | |||
clause = $.trim(clause | |||
if (!clause) { | if (!clause) { | ||
return; | return null; | ||
} | } | ||
var | var words = clause.split(/\s+/); | ||
return $.map(words, function (word, i) { | |||
var | 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; | return prettyNameToken(word, i === 0); | ||
} | }).join(' '); | ||
}).join(', '); | |||
}) | |||
} | } | ||
function prettySoldierLabel(text) { | |||
function | var m = $.trim(text || '').match(/^(.*) \(([^()]+)\)\s*$/); | ||
if (!m || m[1].indexOf('(') !== -1) { | |||
if (! | return prettyNamePhrase(text, true); | ||
return | |||
} | } | ||
text = text. | return prettyNamePhrase(m[1], true) + ' (' + m[2] + ')'; | ||
var | } | ||
function maybePrettySoldierTitle(label) { | |||
var text = $.trim(label || ''); | |||
if (!text || text.indexOf(',') === -1) { | |||
return text; | |||
} | |||
var sur = text.split(',')[0]; | |||
var letters = lettersOnly(sur); | |||
if (!(letters.length >= 2 && letters === letters.toUpperCase())) { | |||
return text; | |||
} | |||
return prettySoldierLabel(text); | |||
} | |||
function applyPrettySoldierLabels($links) { | |||
$links.each(function () { | |||
var $a = $(this); | |||
var label = $a.text(); | |||
var pretty = maybePrettySoldierTitle(label); | |||
if (pretty && pretty !== label) { | |||
$a.text(pretty); | |||
if ($a.attr('title') === label || !$a.attr('title')) { | |||
$a.attr('title', pretty); | |||
} | |||
} | } | ||
}); | |||
} | |||
if (mw.config.get('wgNamespaceNumber') === 14) { | |||
applyPrettySoldierLabels($('.mw-category-group li a')); | |||
} | |||
if (mw.config.get('wgCanonicalSpecialPageName') === 'Search') { | |||
applyPrettySoldierLabels($('.mw-search-result-heading a')); | |||
if ( | } | ||
// Search box suggestions use the real page title; pretty-case ALL-CAPS surnames as they appear. | |||
(function watchSearchSuggestions() { | |||
function polish() { | |||
$('.suggestions-result, .suggestions-result a, .suggestions-special a, .cdx-menu-item, .mw-searchSuggest-link').each(function () { | |||
var $el = $(this); | |||
var label = $.trim($el.text() || ''); | |||
if (rbIsMergedDuplicateTitle(label)) { | |||
$el.closest('li, .cdx-menu-item, .suggestions-result').addBack().filter('li, .cdx-menu-item, .suggestions-result').first().hide(); | |||
} | |||
}); | |||
applyPrettySoldierLabels( | |||
$('.suggestions-result a, .suggestions-special a, .cdx-menu-item__text__label, .mw-searchSuggest-link') | |||
); | |||
$('.suggestions-result, .cdx-menu-item__text__label').each(function () { | |||
var $el = $(this); | |||
if ($el.is('a')) { | |||
return; | return; | ||
} | } | ||
var | var label = $el.text(); | ||
if ( | var pretty = maybePrettySoldierTitle(label); | ||
if (pretty && pretty !== label) { | |||
$el.text(pretty); | |||
} | } | ||
}); | }); | ||
}); | } | ||
var root = document.body; | |||
} | if (!root || !window.MutationObserver) { | ||
return; | |||
} | |||
var obs = new MutationObserver(function () { | |||
polish(); | |||
}); | |||
obs.observe(root, { childList: true, subtree: true }); | |||
}()); | |||
$('.infobox th, .soldier-infobox th, table.wikitable td').each(function () { | |||
var text = $(this).text().replace(/\s+/g, ' ').trim(); | |||
if ( | |||
return; | text === 'Overseas' || | ||
text === 'Over Seas Flag' || | |||
text === 'Special' | |||
) { | |||
$(this).closest('tr').remove(); | |||
} | |||
}); | |||
function teamHref(name) { | |||
return mw.util.getUrl('Team ' + name); | |||
} | |||
function teamLink(name, label) { | |||
return '<a href="' + teamHref(name) + '" title="Team ' + name + '">' + label + '</a>'; | |||
} | } | ||
var | 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); | |||
return; | }); | ||
text = text.replace(/\b(\d{1,4})[Tt]\b/g, function (_, num) { | |||
return teamLink('IPW ' + num, num + 'T'); | |||
}); | |||
text = text.replace(/\b(\d{1,4})\s*\?/g, function (_, num) { | |||
return teamLink('IPW ' + num, num); | |||
}); | |||
return text; | |||
} | } | ||
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 | function dateOnlyFragment(text) { | ||
var | var m = $.trim(text || '').match(/^(\d{1,2}\/\d{1,2}\/\d{2,4})\.?$/); | ||
return | return m ? m[1] : ''; | ||
} | } | ||
function | function joinDateFollowons(parts) { | ||
var | var out = []; | ||
$.each(parts, function (_, clause) { | |||
$ | clause = $.trim(clause || ''); | ||
if (!clause) { | |||
return; | |||
return | |||
} | } | ||
}); | var date = dateOnlyFragment(clause); | ||
return | 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 | function splitNotes(text) { | ||
text = $.trim(text || ''); | |||
if (! | 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; | |||
} | } | ||
return | chunk = chunk.replace(/(\d{1,2}\/\d{1,2}\/\d{2,4})(?=[A-Za-z])/g, '$1\n'); | ||
$.each(chunk.split('\n'), function (__, bit) { | |||
bit = $.trim(bit.replace(/^['"]+|['"]+$/g, '')); | |||
if (!bit) { | |||
return; | |||
} | |||
var dated = bit.match(/^(.*?)\s+(\d{1,2}\/\d{1,2}\/\d{2,4})$/); | |||
if (dated && $.trim(dated[1]) && !/^\d{1,2}\/\d{1,2}\/\d{2,4}$/.test($.trim(dated[1]))) { | |||
parts.push($.trim(dated[1])); | |||
parts.push(dated[2]); | |||
} else { | |||
parts.push(bit); | |||
} | |||
}); | |||
}); | |||
return joinDateFollowons(parts); | |||
} | } | ||
var negative = /fail(?:ed|ure)?|flunk|wash(?:ed)?\s*out|kick(?:ed)?\s*out|dismiss|expel|unsatisfactor|poor\s+(?:performance|work|grades)|awol|court[-\s]?martial|disciplin|discharged\s+for\s+cause|drop(?:ped)?\s+(?:out|from)|relieved|eliminat|unfit|misconduct|desert|excess\s+absence|absence\s*\(|\billness\b|sick\s+call|medical\s+board|psychiatr|neurot|homosexual|suicid|alcohol(?:ism)?|drunk|intoxicat|venereal|\bVD\b|syphili|gonorrh|mental\s+(?:case|illness)|nervous\s+breakdown|not\s+graduat|did\s+not\s+graduate|physically\s+unfit|unfit\s+for|non[-\s]?graduate|certificate\s+listed\s+as\s+ng|(?:^|\b)NG\b(?:\s+Class|\s+OB|\s*$|\s*[,;:/]|\s+\d)|\bnongrad/i; | |||
var $ul = $heading.nextAll('ul').first(); | |||
if (!$ul.length) { | |||
return; | |||
} | } | ||
var items = []; | |||
$ul.find('li').each(function () { | |||
$.each(splitNotes($(this).text()), function (_, clause) { | |||
if (clause) { | |||
items.push(clause); | |||
} | |||
}); | |||
}); | |||
items = joinDateFollowons(items); | |||
var unique = []; | |||
var seen = {}; | |||
$.each(items, function (_, clause) { | |||
var key = normalizeNoteKey(clause); | |||
if (clause && key && !negative.test(clause) && !seen[key]) { | |||
seen[key] = true; | |||
unique.push(clause); | |||
} | |||
}); | |||
items = unique; | |||
$ul.empty(); | |||
$.each(items, function (_, clause) { | |||
$ul.append($('<li>').html(linkifyTeams(clause))); | |||
}); | |||
}); | |||
/** | |||
* Soldier page contribute tools — compact disclosure under Gallery heading. | |||
* Sits in the text column beside the floated infobox (no clear:both gap). | |||
*/ | |||
$(function () { | |||
if (mw.config.get('wgNamespaceNumber') !== 0 || !$('.soldier-infobox').length) { | |||
return; | |||
} | |||
var | 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 $ | function headingText($el) { | ||
var $h = $el.is('h2') ? $el : $el.find('h2').first(); | |||
return $.trim(($h.text() || '').replace(/\s*\[edit.*$/i, '').replace(/\s*edit\s*(source)?\s*/ig, '')); | |||
} | |||
function headingWrap($h2) { | |||
$ | var $wrap = $h2.closest('.mw-heading'); | ||
return $wrap.length ? $wrap : $h2; | |||
$ | |||
} | } | ||
function findSectionHeading(names) { | |||
var re = new RegExp('^(' + names.join('|') + ')$', 'i'); | |||
var $found = $(); | |||
$('h2').each(function () { | |||
if (re.test(headingText($(this)))) { | |||
$found = $(this); | |||
return false; | |||
} | |||
}); | |||
return $found; | |||
$ | |||
} | } | ||
function | function ensureGalleryAnchor() { | ||
var | var $h2 = $('#Gallery'); | ||
var | if (!$h2.length || !$h2.is('h2')) { | ||
var | $h2 = findSectionHeading(['Gallery']); | ||
$ | } | ||
if ($h2.length) { | |||
return headingWrap($h2); | |||
} | |||
var $anchor = findSectionHeading(['Technical record', 'Sources']); | |||
$ | var $wrap = $('<div>', { 'class': 'mw-heading mw-heading2' }); | ||
$ | $wrap.append($('<h2>', { id: 'Gallery' }).text('Gallery')); | ||
if ($anchor.length) { | |||
headingWrap($anchor).before($wrap); | |||
} else { | |||
$('.mw-parser-output').first().append($wrap); | |||
} | } | ||
return $wrap; | |||
} | } | ||
function placeContributeBox($box) { | |||
if ($('.soldier-upload-box').length) { | |||
return; | |||
} | |||
/* Always directly under the Gallery heading — same place on every profile. */ | |||
ensureGalleryAnchor().after($box); | |||
} | |||
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 = $('<details>', { 'class': 'soldier-upload-box' }); | |||
var $summary = $('<summary>').text('Add photos or OCR a document'); | |||
$box.append($summary); | |||
var $body = $('<div>', { 'class': 'soldier-upload-body' }); | |||
$body.append($('<p>', { 'class': 'soldier-contrib-help' }).append( | |||
document.createTextNode('Photos for the portrait or Gallery, or a document scan for OCR review. Also '), | |||
$('<a>', { href: uploadUrl, text: 'Special:Upload' }), | |||
document.createTextNode('.') | |||
)); | |||
var $photoSection = $('<div>', { 'class': 'soldier-contrib-section soldier-contrib-upload' }); | |||
$photoSection.append($('<p>', { 'class': 'soldier-contrib-heading' }).append( | |||
$('<strong>').text('Photo or document upload') | |||
)); | |||
if (!loggedIn) { | |||
$photoSection.append($('<p>', { 'class': 'soldier-upload-login' }).html( | |||
'<a href="' + mw.util.getUrl('Special:UserLogin', { returnto: pageTitle }) + '">Log in</a> to upload files or run document OCR.' | |||
)); | |||
$body.append($photoSection); | |||
$body.append($('<div>', { 'class': 'soldier-ocr-slot' })); | |||
$box.append($body); | |||
placeContributeBox($box); | |||
$( | |||
return; | return; | ||
} | } | ||
var | var $form = $('<form>', { 'class': 'soldier-upload-form' }); | ||
var | var $file = $('<input>', { type: 'file', name: 'ritchieUploadFile', 'class': 'soldier-upload-file' }); | ||
var | var $profile = $('<label>').append( | ||
$('<input>', { type: 'checkbox', name: 'ritchieProfile', checked: true }), | |||
' Use as profile photo (images only)' | |||
); | |||
var $gallery = $('<label>').append( | |||
$('<input>', { type: 'checkbox', name: 'ritchieGallery' }), | |||
' Add to Gallery (documents, or extra photos)' | |||
); | |||
var $status = $('<p>', { 'class': 'soldier-upload-status', text: '' }); | |||
var $submit = $('<button>', { type: 'submit', text: 'Upload file' }); | |||
$form.append($file, $profile, $gallery, $submit, $status); | |||
$photoSection.append($form); | |||
$body.append($photoSection); | |||
$body.append($('<div>', { 'class': 'soldier-ocr-slot' })); | |||
$box.append($body); | |||
placeContributeBox($box); | |||
function setStatus(msg, isError) { | |||
$status.text(msg).css('color', isError ? '#ba0000' : '#202122'); | |||
} | |||
function refreshChecks() { | |||
var name = ($file.val() || '').split(/[/\\]/).pop(); | |||
var isPhoto = PHOTO_EXT.test(name); | |||
var isDoc = DOC_EXT.test(name); | |||
$profile.toggle(isPhoto || !name); | |||
if (isDoc) { | |||
$profile.prop('checked', false); | |||
$gallery.prop('checked', true); | |||
} else if (isPhoto) { | |||
$profile.prop('checked', true); | |||
$gallery.prop('checked', false); | |||
{ | |||
} | } | ||
} | |||
$file.on('change', refreshChecks); | |||
$form.on('submit', function (evt) { | |||
evt.preventDefault(); | |||
var file = $file[0].files[0]; | |||
if (!file) { | |||
setStatus('Choose a file first.', true); | |||
return; | |||
$(' | } | ||
if (!ALLOWED.test(file.name)) { | |||
setStatus('File type not allowed.', true); | |||
return; | |||
$(' | } | ||
var isPhoto = PHOTO_EXT.test(file.name); | |||
var isDoc = DOC_EXT.test(file.name); | |||
var useProfile = $profile.prop('checked') && isPhoto; | |||
var addGallery = $gallery.prop('checked') || isDoc; | |||
if (!useProfile && !addGallery) { | |||
setStatus('Choose profile photo and/or Gallery.', true); | |||
return; | |||
} | |||
$submit.prop('disabled', true); | |||
setStatus('Uploading…'); | |||
var api = new mw.Api(); | |||
var fileText = (isDoc ? 'Document' : 'Portrait') + ' for [[' + pageTitle + ']].'; | |||
api.postWithToken('csrf', { | |||
action: 'upload', | |||
filename: file.name, | |||
file: file, | |||
comment: 'Uploaded from soldier page', | |||
text: fileText, | |||
format: 'json', | |||
ignorewarnings: true | |||
}, { | |||
contentType: 'multipart/form-data' | |||
}).then(function (data) { | |||
var uploaded = data.upload && data.upload.filename; | |||
if (!uploaded) { | |||
throw new Error('Upload failed'); | |||
} | |||
setStatus('Attaching to page…'); | |||
return api.postWithToken('csrf', { | |||
); | action: 'ritchieattachfile', | ||
filename: uploaded, | |||
soldier: pageTitle, | |||
profile: useProfile ? 1 : 0, | |||
gallery: addGallery ? 1 : 0, | |||
format: 'json' | |||
}); | |||
}).done(function () { | |||
setStatus('Done. Reloading…'); | |||
window.location.reload(); | |||
}).fail(function (code, data) { | |||
var err = (data && data.error && data.error.info) || code || 'Upload failed'; | |||
setStatus(err, true); | |||
$submit.prop('disabled', false); | |||
}); | |||
}); | |||
}); | |||
/** | |||
* Soldier page easy-edit form — structured infobox fields without wikitext. | |||
*/ | |||
$(function () { | |||
if (mw.config.get('wgNamespaceNumber') !== 0 || !$('.soldier-infobox').length) { | |||
return; | |||
} | |||
var pageTitle = mw.config.get('wgPageName'); | |||
var editUrl = mw.util.getUrl(pageTitle, { action: 'edit' }); | |||
var sourceUrl = mw.util.getUrl(pageTitle, { action: 'edit', veaction: 'editsource' }); | |||
var FACT_GROUPS = [ | |||
{ | |||
title: 'Personal details', | |||
fields: [ | |||
{ key: 'alias', label: 'Birth name' }, | |||
{ key: 'birth_year', label: 'Birth year', hint: 'Four digits, e.g. 1924' }, | |||
{ key: 'death_year', label: 'Death year', hint: 'Four digits, e.g. 2001' }, | |||
{ key: 'nativity', label: 'Nativity (birthplace)' }, | |||
{ key: 'religion', label: 'Religion' } | |||
] | |||
}, | |||
{ | |||
title: 'Military service', | |||
fields: [ | |||
{ key: 'class_number', label: 'Class number(s)', hint: 'Semicolon for multiple, e.g. 5;11' }, | |||
{ key: 'class_section', label: 'Class section' }, | |||
{ key: 'class_rank', label: 'Class rank' }, | |||
{ key: 'class_branch', label: 'Branch' }, | |||
{ key: 'enlistment_place', label: 'Place of enlistment' }, | |||
{ key: 'ipw_ge', label: 'IPW / German team', hint: 'Roster team number, e.g. 69' }, | |||
{ key: 'ipw_it', label: 'IPW / Italian team' }, | |||
{ key: 'teams', label: 'Additional teams', hint: 'Semicolon-separated, e.g. MII 439-G; IPW 12' }, | |||
{ key: 'rank_final', label: 'Final rank' }, | |||
{ key: 'specialty', label: 'Specialty', hint: 'e.g. IPW Ge, French, German' } | |||
] | |||
}, | |||
{ | |||
title: 'Additional record fields', | |||
collapsed: true, | |||
fields: [ | |||
{ | |||
key: 'other_class', | |||
label: 'Other class', | |||
hint: 'Roster code, e.g. SC8, DC36, MITU4 (shown expanded on the page)' | |||
}, | |||
{ key: 'division', label: 'Unit / division' }, | |||
{ key: 'loc', label: 'LOC' } | |||
] | |||
} | |||
]; | |||
var $ | var $box = $('<div>', { 'class': 'soldier-facts-box' }); | ||
var $intro = $('<p>', { 'class': 'soldier-facts-intro' }); | |||
$ | $intro.append( | ||
$('<strong>', { 'class': 'soldier-facts-label' }).text('Editing this page'), | |||
$('<span>', { 'class': 'soldier-facts-help' }).text(' — '), | |||
$('<button>', { type: 'button', 'class': 'soldier-facts-toggle', text: 'Edit soldier facts' }), | |||
$('<span>', { 'class': 'soldier-facts-help' }).text(' (infobox fields), '), | |||
$('<a>', { href: editUrl, text: 'Edit biography' }), | |||
$('<span>', { 'class': 'soldier-facts-help' }).text(' (visual editor for Biography and notes), or '), | |||
$('<a>', { href: sourceUrl, text: 'Edit source' }), | |||
$('<span>', { 'class': 'soldier-facts-help' }).text(' (full page markup).') | |||
); | |||
$box.append($intro); | |||
var | var $draftBar = $('<p>', { 'class': 'soldier-summary-bar' }); | ||
var | var $draftBtn = $('<button>', { type: 'button', 'class': 'soldier-summary-toggle', text: 'Draft opening summary' }); | ||
$draftBar.append( | |||
$draftBtn, | |||
$('<span>', { 'class': 'soldier-summary-help' }).text( | |||
' — optional 5–10 sentence opening from Personal details and the Biography section. Review before saving.' | |||
) | |||
); | |||
$box.append($draftBar); | |||
var $draftPanel = $('<div>', { 'class': 'soldier-summary-panel', hidden: true }); | |||
var $draftHint = $('<p>', { 'class': 'soldier-summary-hint', text: '' }); | |||
} | var $draftArea = $('<textarea>', { 'class': 'soldier-summary-text', rows: 8 }); | ||
var $draftStatus = $('<p>', { 'class': 'soldier-summary-status', text: '' }); | |||
var $draftUse = $('<button>', { type: 'button', text: 'Use this summary' }); | |||
var $draftClose = $('<button>', { type: 'button', 'class': 'soldier-facts-cancel', text: 'Close' }); | |||
$draftPanel.append( | |||
$draftHint, | |||
$draftArea, | |||
$('<div>', { 'class': 'soldier-facts-actions' }).append($draftUse, ' ', $draftClose), | |||
$draftStatus | |||
); | |||
$box.append($draftPanel); | |||
var $panel = $('<div>', { 'class': 'soldier-facts-panel', hidden: true }); | |||
var $identity = $('<p>', { 'class': 'soldier-facts-identity', text: '' }); | |||
var $form = $('<form>', { 'class': 'soldier-facts-form' }); | |||
var $status = $('<p>', { 'class': 'soldier-facts-status', text: '' }); | |||
var $actions = $('<div>', { 'class': 'soldier-facts-actions' }); | |||
var $save = $('<button>', { type: 'submit', text: 'Save facts' }); | |||
var $cancel = $('<button>', { type: 'button', text: 'Cancel', 'class': 'soldier-facts-cancel' }); | |||
$actions.append($save, ' ', $cancel); | |||
$form.append($identity); | |||
$.each(FACT_GROUPS, function (_, group) { | |||
var $fieldset = $('<fieldset>', { 'class': 'soldier-facts-group' }); | |||
if (group.collapsed) { | |||
$fieldset.addClass('soldier-facts-group-collapsed'); | |||
} | } | ||
$fieldset.append($('<legend>').text(group.title)); | |||
$.each(group.fields, function (__, field) { | |||
var id = 'ritchie-fact-' + field.key; | |||
var $label = $('<label>', { 'for': id, text: field.label }); | |||
var $input = $('<input>', { | |||
type: 'text', | |||
id: id, | |||
var | name: field.key, | ||
var | 'class': 'soldier-facts-input', | ||
$ | 'data-key': field.key | ||
}); | }); | ||
if (field.hint) { | |||
$input.attr('title', field.hint); | |||
$input.attr('placeholder', field.hint); | |||
} | |||
$fieldset.append($('<div>', { 'class': 'soldier-facts-field' }).append($label, $input)); | |||
}); | }); | ||
} | $form.append($fieldset); | ||
}); | |||
$ | $form.append($actions, $status); | ||
$panel.append($form); | |||
$box.append($panel); | |||
var $heading = $('#firstHeading').first(); | |||
if ($heading.length) { | |||
$heading.before($box); | |||
} else { | |||
$ | var $output = $('.mw-parser-output').first(); | ||
$( | $output.prepend($box); | ||
} | |||
} | |||
var loaded = false; | |||
var loading = false; | |||
$ | function setStatus(msg, isError) { | ||
$status.text(msg).css('color', isError ? '#ba0000' : '#202122'); | |||
} | |||
action: ' | |||
function loadFacts() { | |||
if (loaded || loading) { | |||
return $.Deferred().resolve().promise(); | |||
} | |||
loading = true; | |||
setStatus('Loading…'); | |||
return new mw.Api().get({ | |||
action: 'ritchiegetsoldierfacts', | |||
title: pageTitle, | title: pageTitle, | ||
format: 'json' | format: 'json' | ||
}; | }).done(function (data) { | ||
$form.find('.soldier-facts-input').each(function () { | var facts = data.ritchiegetsoldierfacts || {}; | ||
payload[$(this).data('key')] = $(this).val(); | var nameParts = $.grep([facts.first, facts.surname], Boolean); | ||
$identity.text( | |||
(nameParts.join(' ') || pageTitle) + | |||
(facts.asn ? ' (ASN ' + facts.asn + ')' : '') | |||
); | |||
$form.find('.soldier-facts-input').each(function () { | |||
var key = $(this).data('key'); | |||
$(this).val(facts[key] || ''); | |||
}); | |||
loaded = true; | |||
setStatus(''); | |||
}).fail(function (code, data) { | |||
var err = (data && data.error && data.error.info) || code || 'Could not load facts'; | |||
setStatus(err, true); | |||
}).always(function () { | |||
loading = false; | |||
}); | |||
} | |||
$box.find('.soldier-facts-toggle').on('click', function () { | |||
var open = !$panel.prop('hidden'); | |||
if (open) { | |||
$panel.prop('hidden', true); | |||
$(this).text('Edit soldier facts'); | |||
setStatus(''); | |||
return; | |||
} | |||
$panel.prop('hidden', false); | |||
$(this).text('Hide soldier facts form'); | |||
loadFacts(); | |||
}); | |||
$cancel.on('click', function () { | |||
$panel.prop('hidden', true); | |||
$box.find('.soldier-facts-toggle').text('Edit soldier facts'); | |||
setStatus(''); | |||
}); | |||
$form.on('submit', function (evt) { | |||
evt.preventDefault(); | |||
var payload = { | |||
action: 'ritchieeditsoldierfacts', | |||
title: pageTitle, | |||
format: 'json' | |||
}; | |||
$form.find('.soldier-facts-input').each(function () { | |||
payload[$(this).data('key')] = $(this).val(); | |||
}); | }); | ||
| Line 1,331: | Line 1,729: | ||
return ''; | return ''; | ||
} | } | ||
var base = (window.__ocrPublicUrl || '').replace(/\/$/, ''); | var jobMatch = String(path).match(/\/api\/jobs\/([a-fA-F0-9]+)\/preview(?:\?|$)/); | ||
var pageMatch = String(path).match(/\/api\/jobs\/([a-fA-F0-9]+)\/page\/(\d+)\/preview/); | |||
var params; | |||
if (pageMatch) { | |||
params = { | |||
ritchieocr_preview: '1', | |||
job_id: pageMatch[1], | |||
page: pageMatch[2] | |||
}; | |||
return mw.util.wikiScript() + '?' + $.param(params); | |||
} | |||
if (jobMatch) { | |||
params = { | |||
ritchieocr_preview: '1', | |||
job_id: jobMatch[1] | |||
}; | |||
return mw.util.wikiScript() + '?' + $.param(params); | |||
} | |||
/* Legacy fallback if a full absolute URL was stored */ | |||
if (/^https?:\/\//i.test(path)) { | |||
return path; | |||
} | |||
var base = (window.__ocrPublicUrl || '').replace(/\/$/, ''); | |||
if (base) { | if (base) { | ||
return base + path; | return base + path; | ||
| Line 1,340: | Line 1,760: | ||
function publicPreviewUrl(job) { | function publicPreviewUrl(job) { | ||
return publicAssetUrl(job && job.preview_url); | return publicAssetUrl(job && job.preview_url); | ||
} | |||
function timeAgo(ts) { | |||
var sec = Math.max(0, Math.floor(Date.now() / 1000 - Number(ts || 0))); | |||
if (!ts || sec < 60) { | |||
return 'just now'; | |||
} | |||
if (sec < 3600) { | |||
return Math.floor(sec / 60) + 'm ago'; | |||
} | |||
if (sec < 86400) { | |||
return Math.floor(sec / 3600) + 'h ago'; | |||
} | |||
return Math.floor(sec / 86400) + 'd ago'; | |||
} | } | ||
| Line 1,353: | Line 1,787: | ||
{ id: 'other', label: 'Other document' } | { id: 'other', label: 'Other document' } | ||
]; | ]; | ||
/** MediaWiki JSON encodes PHP true as "" and false as null/omitted. */ | |||
function mwApiBool(v) { | |||
if (v === true || v === 1 || v === '1' || v === 'true') { | |||
return true; | |||
} | |||
if (v === false || v === 0 || v === '0' || v === 'false' || v == null) { | |||
return false; | |||
} | |||
if (v === '') { | |||
return true; | |||
} | |||
return !!v; | |||
} | |||
function isMultiPersonJob(job) { | |||
if (!job) { | |||
return false; | |||
} | |||
if (mwApiBool(job.multi_person)) { | |||
return true; | |||
} | |||
var detected = job.people_summary && job.people_summary.detected; | |||
if (Number(detected) > 1) { | |||
return true; | |||
} | |||
var keys = {}; | |||
(job.proposed || []).forEach(function (p) { | |||
if (p.person_key) { | |||
keys[p.person_key] = 1; | |||
} | |||
}); | |||
return Object.keys(keys).length > 1; | |||
} | |||
function factAlreadyOnPage(fact) { | |||
return !!(fact && mwApiBool(fact.already_on_page)); | |||
} | |||
function factSelected(fact) { | |||
return !!(fact && mwApiBool(fact.selected)); | |||
} | |||
function changeLineForFact(fact) { | function changeLineForFact(fact) { | ||
| Line 1,358: | Line 1,834: | ||
var val = (fact.value || '').trim(); | var val = (fact.value || '').trim(); | ||
var label = (fact.label || '').toLowerCase(); | var label = (fact.label || '').toLowerCase(); | ||
if (fact. | if (factAlreadyOnPage(fact) && (field === 'ipw_ge' || field === 'teams')) { | ||
return 'Team already on their profile (' + (val || fact.existing_value || 'same') + ') — no change'; | |||
} | |||
if (factAlreadyOnPage(fact)) { | |||
return (fact.label || 'Already on page') + ' — skipped'; | return (fact.label || 'Already on page') + ' — skipped'; | ||
} | } | ||
if (field === 'ipw_ge') { | if (field === 'ipw_ge') { | ||
return 'Team | return 'Set their Team (IPW) to ' + val; | ||
} | } | ||
if (field === 'teams') { | if (field === 'teams') { | ||
return ' | return 'Add ' + val + ' to their Teams list'; | ||
} | } | ||
if (field === 'asn') { | if (field === 'asn') { | ||
return 'ASN | return 'Set ASN to ' + val; | ||
} | } | ||
if (field === 'rank_final') { | if (field === 'rank_final') { | ||
return ' | return 'Set rank to ' + val; | ||
} | } | ||
if (field === 'specialty') { | if (field === 'specialty') { | ||
return ' | return 'Set specialty/role to ' + val; | ||
} | } | ||
if (field === 'class_number') { | if (field === 'class_number') { | ||
return ' | return 'Set class to ' + val; | ||
} | } | ||
if (fact.kind === 'bio') { | if (fact.kind === 'bio') { | ||
return ' | return 'Add to biography: ' + (val.length > 80 ? val.slice(0, 80) + '…' : val); | ||
} | } | ||
if (fact.kind === 'note') { | if (fact.kind === 'note') { | ||
return (fact.label || ' | return 'Add research note — ' + (fact.label || 'OCR note') + ': ' + | ||
(val.length > 90 ? val.slice(0, 90) + '…' : val); | |||
} | } | ||
if (label.indexOf('team') !== -1 || label.indexOf('ipw') !== -1) { | if (label.indexOf('team') !== -1 || label.indexOf('ipw') !== -1) { | ||
| Line 1,396: | Line 1,876: | ||
} | } | ||
function | function personMetaFromItems(items) { | ||
var | var rank = ''; | ||
var role = ''; | |||
var asn = ''; | |||
var team = ''; | |||
items.forEach(function (f) { | items.forEach(function (f) { | ||
if (f.field === ' | if (f.field === 'rank_final' && f.value && !rank) { | ||
rank = f.value; | |||
} | } | ||
if (f.field === ' | if (f.field === 'specialty' && f.value && !role) { | ||
role = f.value; | |||
} | } | ||
if (f.field === ' | if (f.field === 'asn' && f.value && !asn) { | ||
asn = f.value; | |||
} | } | ||
if ((f.field === 'ipw_ge' || f.field === 'teams') && f.value) { | if ((f.field === 'ipw_ge' || f.field === 'teams') && f.value && !team) { | ||
team = f.value.indexOf('IPW') === 0 ? f.value : ('IPW ' + f.value); | |||
} | } | ||
}); | }); | ||
return | return { rank: rank, role: role, asn: asn, team: team }; | ||
} | } | ||
| Line 1,432: | Line 1,912: | ||
} | } | ||
function isWritableFact(fact) { | function isWritableFact(fact, applyNotes) { | ||
return fact.kind === 'field' || fact.kind === 'bio'; | if (!fact) { | ||
} | return false; | ||
} | |||
if (fact.kind === 'field' || fact.kind === 'bio') { | |||
return true; | |||
} | |||
return !!(applyNotes && fact.kind === 'note'); | |||
} | |||
/** Team membership is the main action on multi-person roster docs. */ | |||
function isPrimaryTeamFact(fact) { | |||
return fact.field === 'ipw_ge' || fact.field === 'teams'; | |||
} | |||
function profileUpdateSentence(fact) { | |||
if (!fact || !isPrimaryTeamFact(fact)) { | |||
return changeLineForFact(fact); | |||
} | |||
var val = (fact.value || '').trim(); | |||
if (factAlreadyOnPage(fact)) { | |||
return 'Their profile already lists this team — nothing to write.'; | |||
} | |||
if (fact.field === 'teams') { | |||
return 'On their soldier page, add ' + val + ' under Teams (they already have a different primary team).'; | |||
} | |||
return 'On their soldier page, set Team to IPW ' + val + '.'; | |||
} | |||
function bindSoldierTypeahead($input, $suggest, onPick) { | function bindSoldierTypeahead($input, $suggest, onPick) { | ||
| Line 1,453: | Line 1,958: | ||
redirects: 'resolve' | redirects: 'resolve' | ||
}).then(function (data) { | }).then(function (data) { | ||
var titles = (data && data[1]) || []; | var titles = ((data && data[1]) || []).filter(function (t) { | ||
return !rbIsMergedDuplicateTitle(t); | |||
}); | |||
$suggest.empty(); | $suggest.empty(); | ||
if (!titles.length) { | if (!titles.length) { | ||
| Line 1,475: | Line 1,982: | ||
function renderChangeRow(fact, job, opts) { | function renderChangeRow(fact, job, opts) { | ||
opts = opts || {}; | opts = opts || {}; | ||
var writable = isWritableFact(fact) && !fact. | var writable = isWritableFact(fact) && !factAlreadyOnPage(fact); | ||
var forceSelected = opts.forceSelected; | |||
var selected = writable && (forceSelected === undefined ? factSelected(fact) : !!forceSelected); | |||
var $row = $('<div>', { | var $row = $('<div>', { | ||
'class': 'ocr-fact-row' + | 'class': 'ocr-fact-row' + | ||
(fact | (factAlreadyOnPage(fact) ? ' ocr-fact-already' : '') + | ||
(writable ? ' ocr-fact-writable' : ' ocr-fact-note') + | (writable ? ' ocr-fact-writable' : ' ocr-fact-note') + | ||
(isPrimaryTeamFact(fact) ? ' ocr-fact-primary' : ' ocr-fact-secondary') + | |||
(fact.kind === 'note' ? ' ocr-fact-is-note' : ''), | (fact.kind === 'note' ? ' ocr-fact-is-note' : ''), | ||
'data-id': fact.id, | 'data-id': fact.id, | ||
| Line 1,488: | Line 1,998: | ||
var $check = $('<input>', { | var $check = $('<input>', { | ||
type: 'checkbox', | type: 'checkbox', | ||
checked: | checked: selected, | ||
disabled: !writable | disabled: !writable | ||
}); | }); | ||
var $line = $('<span>', { 'class': 'ocr-change-line' }).text(changeLineForFact(fact)); | var $line = $('<span>', { 'class': 'ocr-change-line' }).text( | ||
opts.plainSentence ? profileUpdateSentence(fact) : changeLineForFact(fact) | |||
); | |||
var $val = $('<input>', { | var $val = $('<input>', { | ||
type: 'text', | type: 'text', | ||
| Line 1,503: | Line 2,015: | ||
}); | }); | ||
var $details = $('<details>', { 'class': 'ocr-fact-details' }); | var $details = $('<details>', { 'class': 'ocr-fact-details' }); | ||
$details.append($('<summary>').text('Edit')); | $details.append($('<summary>').text('Edit value')); | ||
$details.append($('<label>').text('Value ').append($val)); | $details.append($('<label>').text('Value ').append($val)); | ||
$details.append($('<span>', { 'class': 'ocr-fact-meta' }).text( | $details.append($('<span>', { 'class': 'ocr-fact-meta' }).text( | ||
(fact.kind || '') + ' · conf ' + (fact.confidence || 0) + '%' | 'field ' + (fact.field || fact.kind || '') + ' · conf ' + (fact.confidence || 0) + '%' | ||
)); | )); | ||
if (writable || fact | if (writable || factAlreadyOnPage(fact)) { | ||
$row.append($check, $line, $tgt, $details); | $row.append($check, $line, $tgt, $details); | ||
} else { | } else { | ||
$row.append($line, $tgt, $details); | $row.append($line, $tgt, $details); | ||
} | } | ||
if (!writable) { | if (!writable) { | ||
$row.append($('<input>', { type: 'checkbox', checked: false, 'class': 'ocr-fact-check-hidden' }).hide()); | $row.append($('<input>', { type: 'checkbox', checked: false, 'class': 'ocr-fact-check-hidden' }).hide()); | ||
| Line 1,540: | Line 2,035: | ||
function renderPersonCards($list, proposed, job, onChange) { | function renderPersonCards($list, proposed, job, onChange) { | ||
var grouped = groupFactsByPerson(proposed); | var grouped = groupFactsByPerson(proposed); | ||
var lastGroup = null; | |||
var $table = $('<table>', { 'class': 'ocr-roster-table' }); | |||
var $thead = $('<thead>').append($('<tr>').append( | |||
$('<th>', { 'class': 'ocr-col-do' }).text('Write?'), | |||
$('<th>').text('Person on the scan'), | |||
$('<th>').text('Wiki soldier page'), | |||
$('<th>').text('What changes on that page') | |||
)); | |||
var $tbody = $('<tbody>'); | |||
$table.append($thead, $tbody); | |||
grouped.order.forEach(function (key) { | grouped.order.forEach(function (key) { | ||
var items = grouped.byPerson[key]; | var items = grouped.byPerson[key]; | ||
var first = items[0] || {}; | var first = items[0] || {}; | ||
var title = first.target_title || ''; | var title = first.target_title || ''; | ||
var | var meta = personMetaFromItems(items); | ||
return isWritableFact(f) && !f | var groupId = first.group_id || ''; | ||
var teamFacts = items.filter(isPrimaryTeamFact); | |||
var secondary = items.filter(function (f) { | |||
return isWritableFact(f) && !isPrimaryTeamFact(f) && !factAlreadyOnPage(f); | |||
}); | }); | ||
var notes = items.filter(function (f) { | var notes = items.filter(function (f) { | ||
return f.kind === 'note' | return f.kind === 'note'; | ||
}); | }); | ||
var included = | var teamFact = teamFacts[0]; | ||
var $card = $('< | var teamDone = !!(teamFact && factAlreadyOnPage(teamFact)); | ||
'class': 'ocr-person-card' + (title ? ' ocr-person-matched' : ' ocr-person-unresolved'), | var teamReady = !!(teamFact && !factAlreadyOnPage(teamFact) && (teamFact.value || '').trim()); | ||
var included = !!(title && teamReady && factSelected(teamFact)); | |||
var teamLabel = (teamFact && (teamFact.value || '').trim()) | |||
? ('IPW ' + String(teamFact.value).replace(/^IPW\s+/i, '')) | |||
: (meta.team || 'this team'); | |||
if (groupId && groupId !== lastGroup) { | |||
lastGroup = groupId; | |||
var groupLabel = meta.team | |||
? ('Team ' + meta.team) | |||
: ('Group ' + groupId.replace(/^g/i, '')); | |||
var inGroup = grouped.order.filter(function (k) { | |||
return (grouped.byPerson[k][0] || {}).group_id === groupId; | |||
}).length; | |||
$tbody.append($('<tr>', { 'class': 'ocr-group-heading-row' }).append( | |||
$('<td>', { colspan: 4 }).text( | |||
groupLabel + ' — ' + inGroup + ' people on this document' | |||
) | |||
)); | |||
} | |||
var $card = $('<tr>', { | |||
'class': 'ocr-person-card' + | |||
(title ? ' ocr-person-matched' : ' ocr-person-unresolved') + | |||
(teamDone ? ' ocr-person-done' : ''), | |||
'data-person': key | 'data-person': key | ||
}); | }); | ||
var $include = $('<input>', { | var $include = $('<input>', { | ||
type: 'checkbox', | type: 'checkbox', | ||
'class': 'ocr-include', | 'class': 'ocr-include', | ||
checked: included | checked: included, | ||
title: teamDone ? 'This team is already on their profile' : 'Write the team onto their soldier page' | |||
}); | }); | ||
if (teamDone || !teamFact) { | |||
$include.prop('disabled', true).prop('checked', false); | |||
if ( | |||
$ | |||
} | } | ||
var $tgtShared = $('<input>', { | var $tgtShared = $('<input>', { | ||
type: 'text', | type: 'text', | ||
'class': 'ocr-person-title', | 'class': 'ocr-person-title', | ||
value: title, | value: title, | ||
placeholder: ' | placeholder: 'Search surname or ASN to match a page…' | ||
}); | }); | ||
var $suggest = $('<div>', { 'class': 'ocr-suggest' }).hide(); | var $suggest = $('<div>', { 'class': 'ocr-suggest' }).hide(); | ||
var $pageLink = $('<div>', { 'class': 'ocr-wiki-page-name' }); | |||
var $matchStatus = $('<div>', { 'class': 'ocr-match-status' }); | var $matchStatus = $('<div>', { 'class': 'ocr-match-status' }); | ||
function setMatch(t) { | var $outcome = $('<div>', { 'class': 'ocr-row-outcome' }); | ||
title = t || ''; | |||
$tgtShared.val(title); | function syncPrimaryChecks() { | ||
$card.find('.ocr-fact-target').val(title); | var on = $include.prop('checked') && !!$.trim($tgtShared.val() || '') && !teamDone; | ||
if (title) { | $card.find('.ocr-fact-primary.ocr-fact-writable input[type="checkbox"]').prop('checked', on); | ||
if (!on) { | |||
$card.find('.ocr-fact-secondary.ocr-fact-writable input[type="checkbox"]').prop('checked', false); | |||
} | |||
} | |||
function refreshOutcome() { | |||
var page = $.trim($tgtShared.val() || ''); | |||
var extra = []; | |||
$card.find('.ocr-fact-secondary.ocr-fact-writable').each(function () { | |||
var $row = $(this); | |||
if ($row.find('input[type="checkbox"]').first().prop('checked')) { | |||
extra.push($row.find('.ocr-change-line').text()); | |||
} | |||
}); | |||
$outcome.removeClass( | |||
'ocr-outcome-write ocr-outcome-skip ocr-outcome-unmatched ocr-outcome-idle' | |||
); | |||
if (!page) { | |||
$include.prop('checked', false).prop('disabled', true); | |||
syncPrimaryChecks(); | |||
$outcome.addClass('ocr-outcome-unmatched'); | |||
$outcome.text( | |||
'No wiki page matched. Nothing will be written until you search and pick their soldier page' + | |||
(teamFact && teamFact.value ? (', then Team ' + teamLabel + ' can be added.') : '.') | |||
); | |||
return; | |||
} | |||
if (!teamFact) { | |||
$include.prop('disabled', true).prop('checked', false); | |||
if (extra.length) { | |||
$outcome.addClass('ocr-outcome-write'); | |||
var $ul0 = $('<ul>', { 'class': 'ocr-row-write-list' }); | |||
extra.forEach(function (line) { | |||
$ul0.append($('<li>').text(line)); | |||
}); | |||
$outcome.empty().append($ul0); | |||
} else { | |||
$outcome.addClass('ocr-outcome-idle'); | |||
$outcome.text( | |||
'Matched. This scan has no team number to write. Rank/ASN/role stay as cues unless you open extra fields.' | |||
); | |||
} | |||
return; | |||
} | |||
if (teamDone && !extra.length) { | |||
$include.prop('disabled', true).prop('checked', false); | |||
$outcome.addClass('ocr-outcome-skip'); | |||
$outcome.empty().append( | |||
document.createTextNode('Already on their profile: Team ' + teamLabel + '. Nothing new will be written.') | |||
); | |||
return; | |||
} | |||
$include.prop('disabled', false); | |||
if ($include.prop('checked') || extra.length) { | |||
$outcome.addClass('ocr-outcome-write'); | |||
var $ul = $('<ul>', { 'class': 'ocr-row-write-list' }); | |||
if ($include.prop('checked') && teamReady) { | |||
$ul.append($('<li>').text('Set Team to ' + teamLabel + ' on that soldier page')); | |||
} | |||
extra.forEach(function (line) { | |||
$ul.append($('<li>').text(line)); | |||
}); | |||
$outcome.empty().append($ul); | |||
return; | |||
} | |||
$outcome.addClass('ocr-outcome-idle'); | |||
$outcome.text( | |||
'Matched, but not selected. Check “Write?” to add Team ' + teamLabel + ' to that soldier page.' | |||
); | |||
} | |||
function setMatch(t, silent) { | |||
title = t || ''; | |||
$tgtShared.val(title); | |||
$card.find('.ocr-fact-target').val(title); | |||
$pageLink.empty(); | |||
if (title) { | |||
$card.removeClass('ocr-person-unresolved').addClass('ocr-person-matched'); | $card.removeClass('ocr-person-unresolved').addClass('ocr-person-matched'); | ||
$ | $pageLink.append($('<a>', { | ||
href: mw.util.getUrl(title), | |||
text: title, | |||
); | target: '_blank' | ||
})); | |||
$matchStatus.text('Open the page in a new tab to see their current profile.'); | |||
} else { | } else { | ||
$card.removeClass('ocr-person-matched').addClass('ocr-person-unresolved'); | $card.removeClass('ocr-person-matched').addClass('ocr-person-unresolved'); | ||
$matchStatus.text(' | $pageLink.append($('<span>', { 'class': 'ocr-wiki-missing' }).text('Not matched to a soldier page')); | ||
$matchStatus.text('Type a surname or ASN below, then pick the page.'); | |||
$include.prop('checked', false); | $include.prop('checked', false); | ||
syncPrimaryChecks(); | |||
} | } | ||
if (onChange) { | refreshOutcome(); | ||
if (!silent && onChange) { | |||
onChange(); | onChange(); | ||
} | } | ||
} | } | ||
if ( | |||
var $do = $('<td>', { 'class': 'ocr-col-do' }); | |||
} | $do.append($include); | ||
$ | |||
var $who = $('<td>', { 'class': 'ocr-col-who' }); | |||
$who.append($('<div>', { 'class': 'ocr-scan-name' }).text(personDisplayName(first))); | |||
var whoBits = []; | |||
if (meta.rank) { | |||
whoBits.push(meta.rank); | |||
} | |||
if (meta.role) { | |||
whoBits.push(meta.role); | |||
} | |||
if (meta.asn) { | |||
whoBits.push('ASN ' + meta.asn); | |||
} | |||
if (whoBits.length) { | |||
$who.append($('<div>', { 'class': 'ocr-scan-bits' }).text( | |||
'From the scan (not written): ' + whoBits.join(' · ') | |||
)); | |||
} | } | ||
$ | var $page = $('<td>', { 'class': 'ocr-col-page' }); | ||
$ | $page.append($pageLink, $matchStatus, $tgtShared, $suggest); | ||
bindSoldierTypeahead($tgtShared, $suggest, setMatch); | bindSoldierTypeahead($tgtShared, $suggest, setMatch); | ||
$tgtShared.on('change', function () { | $tgtShared.on('change', function () { | ||
| Line 1,617: | Line 2,245: | ||
if (candidates && candidates.length) { | if (candidates && candidates.length) { | ||
var $pick = $('<select>', { 'class': 'ocr-match-pick' }); | var $pick = $('<select>', { 'class': 'ocr-match-pick' }); | ||
$pick.append($('<option>', { value: '', text: 'Suggested | $pick.append($('<option>', { value: '', text: 'Suggested wiki pages…' })); | ||
candidates.forEach(function (t) { | candidates.forEach(function (t) { | ||
$pick.append($('<option>', { value: t, text: t, selected: t === title })); | $pick.append($('<option>', { value: t, text: t, selected: t === title })); | ||
| Line 1,626: | Line 2,254: | ||
} | } | ||
}); | }); | ||
$ | $page.append($pick); | ||
} | } | ||
var $ | var $action = $('<td>', { 'class': 'ocr-col-action' }); | ||
$action.append($outcome); | |||
var $ | |||
$ | var $store = $('<div>', { 'class': 'ocr-fact-store' }); | ||
$ | if (teamFact) { | ||
var $teamRow = renderChangeRow(teamFact, job, { | |||
plainSentence: true, | |||
forceSelected: included && !teamDone | |||
}); | |||
$teamRow.find('.ocr-fact-target').val(title); | |||
$teamRow.find('input[type="checkbox"]').first().addClass('ocr-primary-check'); | |||
$store.append($teamRow); | |||
} | |||
notes.forEach(function (fact) { | |||
var $nrow = renderChangeRow(fact, job, {}); | |||
$nrow.find('.ocr-fact-target').val(title); | |||
$store.append($nrow); | |||
}); | }); | ||
$action.append($store); | |||
$ | |||
if (secondary.length) { | |||
if ( | var $moreFields = $('<details>', { 'class': 'ocr-card-more' }); | ||
var $ | $moreFields.append($('<summary>').text( | ||
$ | 'Also write rank / ASN / role (' + secondary.length + ') — off unless you check them' | ||
)); | |||
var $row = renderChangeRow(fact, job, { | secondary.forEach(function (fact) { | ||
var $row = renderChangeRow(fact, job, { forceSelected: false }); | |||
$row.find('.ocr-fact-target').val(title); | $row.find('.ocr-fact-target').val(title); | ||
$ | $moreFields.append($row); | ||
}); | }); | ||
$ | $action.append($moreFields); | ||
} | } | ||
$include.on('change', function () { | $include.on('change', function () { | ||
if ($include.prop('checked') && !$.trim($tgtShared.val() || '')) { | |||
$include.prop('checked', false); | $include.prop('checked', false); | ||
$matchStatus.text('Match a soldier page first, then | $matchStatus.text('Match a soldier page first, then check Write?.'); | ||
refreshOutcome(); | |||
return; | return; | ||
} | } | ||
$ | if ($include.prop('checked') && teamDone) { | ||
$include.prop('checked', false); | |||
refreshOutcome(); | |||
return; | |||
} | |||
syncPrimaryChecks(); | |||
refreshOutcome(); | |||
if (onChange) { | if (onChange) { | ||
onChange(); | onChange(); | ||
} | } | ||
}); | }); | ||
$card.on('change', 'input[type="checkbox"], input.ocr-fact-value', function () { | |||
$card.append($do, $who, $page, $action); | |||
$card.on('change', 'input[type="checkbox"], input.ocr-fact-value', function (ev) { | |||
if ($(ev.target).hasClass('ocr-include')) { | |||
return; | |||
} | |||
refreshOutcome(); | |||
if (onChange) { | if (onChange) { | ||
onChange(); | onChange(); | ||
} | } | ||
}); | }); | ||
$ | $tbody.append($card); | ||
setMatch(title, true); | |||
}); | }); | ||
$list.append($table); | |||
} | } | ||
| Line 1,679: | Line 2,329: | ||
var $card = $('<div>', { 'class': 'ocr-person-card ocr-person-matched ocr-single-card' }); | var $card = $('<div>', { 'class': 'ocr-person-card ocr-person-matched ocr-single-card' }); | ||
var title = job.bound_title || ''; | var title = job.bound_title || ''; | ||
$card.append($('<div>', { 'class': 'ocr-card-who-label' }).text('Changes for this soldier page')); | |||
$card.append($('<div>', { 'class': 'ocr-card-name' }).text(title || 'This soldier')); | $card.append($('<div>', { 'class': 'ocr-card-name' }).text(title || 'This soldier')); | ||
if (title) { | if (title) { | ||
| Line 1,689: | Line 2,340: | ||
var primary = proposed.filter(function (f) { | var primary = proposed.filter(function (f) { | ||
return isWritableFact(f) && !f | return isWritableFact(f) && !factAlreadyOnPage(f); | ||
}); | }); | ||
var skipped = proposed.filter(function (f) { return f | var skipped = proposed.filter(function (f) { return factAlreadyOnPage(f); }); | ||
var notes = proposed.filter(function (f) { return f.kind === 'note'; }); | var notes = proposed.filter(function (f) { return f.kind === 'note'; }); | ||
| Line 1,699: | Line 2,350: | ||
} | } | ||
primary.forEach(function (fact) { | primary.forEach(function (fact) { | ||
var $row = renderChangeRow(fact, job, { hideTargetVisible: true }); | var $row = renderChangeRow(fact, job, { hideTargetVisible: true, plainSentence: true }); | ||
$row.find('.ocr-fact-target').val(title); | $row.find('.ocr-fact-target').val(title); | ||
$changes.append($row); | $changes.append($row); | ||
}); | }); | ||
skipped.forEach(function (fact) { | if (skipped.length) { | ||
var $skip = $('<details>', { 'class': 'ocr-card-more' }); | |||
$skip.append($('<summary>').text('Already on page (' + skipped.length + ')')); | |||
skipped.forEach(function (fact) { | |||
$skip.append(renderChangeRow(fact, job, { hideTargetVisible: true })); | |||
}); | |||
$changes.append($skip); | |||
} | |||
$card.append($changes); | $card.append($changes); | ||
| Line 1,765: | Line 2,421: | ||
function renderReview($host, job, cfg) { | function renderReview($host, job, cfg) { | ||
$host.empty(); | $host.empty(); | ||
var $panel = $('<div>', { 'class': 'ocr-review-panel ocr-review-v2' }); | var $panel = $('<div>', { 'class': 'ocr-review-panel ocr-review-v2' + (isMultiPersonJob(job) ? ' ocr-review-roster' : '') }); | ||
$panel.append($('<p>').append( | if (isMultiPersonJob(job)) { | ||
$panel.append($('<p>').append( | |||
$('<strong>').text('Each row is one person'), | |||
)); | document.createTextNode( | ||
' — the last column is the only thing that would be written on a soldier page. Rank, role, and ASN on the left are from the scan (for matching). Nothing is saved until Apply.' | |||
) | |||
)); | |||
} else { | |||
$panel.append($('<p>').append( | |||
$('<strong>').text('Confirm changes, then Apply'), | |||
document.createTextNode(' — nothing is saved until you click Apply.') | |||
)); | |||
} | |||
if (job.source_kind === 'facebook_seed') { | |||
$panel.append($('<div>', { 'class': 'ocr-seed-banner' }).append( | |||
$('<strong>').text('Facebook OCR seed'), | |||
document.createTextNode( | |||
' — review #' + (job.source_ref || '?') + | |||
'. Text is from a prior gallery OCR; verify against the File on the soldier page before Apply.' | |||
) | |||
)); | |||
} | |||
if (job | if (isMultiPersonJob(job) && job.people_summary) { | ||
var ps = job.people_summary; | var ps = job.people_summary; | ||
$ | var $banner = $('<div>', { 'class': 'ocr-outcome-banner' }); | ||
$banner.append($('<strong>').text( | |||
(ps.detected || 0) + ' people on this document' | |||
(ps. | |||
)); | )); | ||
var bannerBits = []; | |||
bannerBits.push((ps.matched || 0) + ' matched to a wiki page'); | |||
bannerBits.push((ps.unresolved || 0) + ' not matched yet'); | |||
if (ps.already_on_page) { | |||
bannerBits.push((ps.already_on_page) + ' already have this team on their profile (nothing to write)'); | |||
} | |||
if (ps.inferred_teams && ps.inferred_teams.length) { | |||
bannerBits.push('teams ' + ps.inferred_teams.join(', ')); | |||
} else if (ps.teams && ps.teams.length) { | |||
bannerBits.push('teams ' + ps.teams.join(', ')); | |||
} | |||
$banner.append($('<div>').text(bannerBits.join(' · '))); | |||
$panel.append($banner); | |||
} else { | } else { | ||
var engineBit = job.ocr_engine ? (' · Engine: ' + job.ocr_engine) : ''; | |||
$panel.append($('<p>', { 'class': 'ocr-fact-meta' }).text( | $panel.append($('<p>', { 'class': 'ocr-fact-meta' }).text( | ||
(job.doc_type_label || 'Document') + | (job.doc_type_label || 'Document') + | ||
' · OCR quality ' + (job.ocr_quality || 0) + '%' + | ' · OCR quality ' + (job.ocr_quality || 0) + '%' + | ||
engineBit + | |||
(job.error ? (' · Error: ' + job.error) : '') | (job.error ? (' · Error: ' + job.error) : '') | ||
)); | )); | ||
if (job.ocr_engine_notes && job.ocr_engine_notes.length) { | |||
$panel.append($('<p>', { 'class': 'ocr-fact-meta' }).text( | |||
job.ocr_engine_notes.slice(0, 3).join(' · ') | |||
)); | |||
} | |||
} | } | ||
if (job.identity_warning) { | if (job.identity_warning) { | ||
var $id = $('<div>', { | var $id = $('<div>', { | ||
'class': 'ocr-identity-banner' + (job.identity_ok ? ' ocr-identity-ok' : ' ocr-identity-warn') | 'class': 'ocr-identity-banner' + (mwApiBool(job.identity_ok) ? ' ocr-identity-ok' : ' ocr-identity-warn') | ||
}); | }); | ||
$id.append($('<strong>').text(job.identity_ok ? 'Identity check: ' : 'Identity warning: ')); | $id.append($('<strong>').text(mwApiBool(job.identity_ok) ? 'Identity check: ' : 'Identity warning: ')); | ||
$id.append(document.createTextNode(job.identity_warning)); | $id.append(document.createTextNode(job.identity_warning)); | ||
$panel.append($id); | $panel.append($id); | ||
} | |||
if (mwApiBool(job.pdf_truncated)) { | |||
$panel.append($('<div>', { 'class': 'ocr-identity-banner ocr-identity-warn' }).append( | |||
$('<strong>').text('PDF truncated: '), | |||
document.createTextNode( | |||
'Only the first ' + (job.page_count || 8) + ' of ' + | |||
(job.pdf_total_pages || '?') + | |||
' pages were OCR’d. Split the PDF or re-upload the remaining pages.' | |||
) | |||
)); | |||
} | |||
if (job.error) { | |||
$panel.append($('<div>', { 'class': 'ocr-identity-banner ocr-identity-warn' }).text(job.error)); | |||
} | } | ||
| Line 1,830: | Line 2,535: | ||
var $previewBox = $('<div>', { 'class': 'ocr-apply-preview' }); | var $previewBox = $('<div>', { 'class': 'ocr-apply-preview' }); | ||
$previewBox.append($('<strong>').text(' | $previewBox.append($('<strong>').text('Soldier pages that will be edited')); | ||
$previewBox.append($('<p>', { 'class': 'ocr-fact-meta ocr-preview-hint' }).text( | |||
isMultiPersonJob(job) | |||
? 'Grouped by wiki page. Rank/role/ASN from the scan are not listed here unless you check them under a row.' | |||
: 'Checked items below will be written on Apply.' | |||
)); | |||
var $previewCount = $('<p>', { 'class': 'ocr-preview-count' }); | |||
var $previewUl = $('<ul>', { 'class': 'ocr-apply-preview-list' }); | var $previewUl = $('<ul>', { 'class': 'ocr-apply-preview-list' }); | ||
$previewBox.append($previewUl); | $previewBox.append($previewCount, $previewUl); | ||
function refreshPreview() { | function refreshPreview() { | ||
var applyNotesOn = !!$panel.find('.ocr-apply-notes input').prop('checked'); | |||
var props = collectProposed(); | var props = collectProposed(); | ||
var | var byPage = {}; | ||
props.forEach(function (p) { | props.forEach(function (p) { | ||
if (!p.selected || !isWritableFact(p) || p | if (!p.selected || !isWritableFact(p, applyNotesOn) || factAlreadyOnPage(p)) { | ||
return; | return; | ||
} | } | ||
var page = (p.target_title || '').trim(); | |||
if (!page) { | |||
return; | return; | ||
} | } | ||
if (!byPage[page]) { | |||
byPage[page] = []; | |||
} | |||
byPage[page].push(p); | |||
}); | }); | ||
var pages = Object.keys(byPage); | |||
$previewUl.empty(); | $previewUl.empty(); | ||
if (! | if (!pages.length) { | ||
$previewUl.append($('<li>', { 'class': 'ocr-fact-meta' }).text('Nothing selected yet.')); | $previewCount.text(''); | ||
$previewUl.append($('<li>', { 'class': 'ocr-fact-meta' }).text( | |||
isMultiPersonJob(job) | |||
? 'Nothing will be written on Apply. Check Write? on a row (after matching a wiki page) to add that person.' | |||
: 'Nothing selected yet.' | |||
)); | |||
} else { | } else { | ||
$previewCount.text( | |||
$ | 'Apply will edit ' + pages.length + ' soldier page' + (pages.length === 1 ? '' : 's') + ':' | ||
); | |||
pages.forEach(function (page) { | |||
var $li = $('<li>', { 'class': 'ocr-preview-person' }); | |||
$li.append($('<a>', { href: mw.util.getUrl(page), text: page, target: '_blank' })); | |||
var $sub = $('<ul>'); | |||
byPage[page].forEach(function (p) { | |||
$sub.append($('<li>').text(profileUpdateSentence(p))); | |||
}); | |||
$li.append($sub); | |||
$previewUl.append($li); | |||
}); | }); | ||
} | } | ||
} | } | ||
if (job | $facts.append($previewBox); | ||
if (isMultiPersonJob(job)) { | |||
var $toolbar = $('<div>', { 'class': 'ocr-multi-toolbar' }); | var $toolbar = $('<div>', { 'class': 'ocr-multi-toolbar' }); | ||
var $selMatched = $('<button>', { type: 'button', text: ' | var $selMatched = $('<button>', { type: 'button', text: 'Write team for every matched person who still needs it' }); | ||
var $deselUnres = $('<button>', { type: 'button', text: ' | var $deselUnres = $('<button>', { type: 'button', text: 'Clear unmatched' }); | ||
$toolbar.append($selMatched, $deselUnres); | $toolbar.append($selMatched, $deselUnres); | ||
$facts.append($toolbar); | $facts.append($toolbar); | ||
renderPersonCards($list, proposed, job, refreshPreview); | renderPersonCards($list, proposed, job, refreshPreview); | ||
$selMatched.on('click', function () { | $selMatched.on('click', function () { | ||
$list.find('.ocr-person-matched').each(function () { | $list.find('.ocr-person-matched:not(.ocr-person-done)').each(function () { | ||
var $card = $(this); | var $card = $(this); | ||
$card.find('.ocr-include').prop(' | $card.find('.ocr-include').prop('disabled', false).prop('checked', true).trigger('change'); | ||
}); | }); | ||
refreshPreview(); | refreshPreview(); | ||
}); | }); | ||
$deselUnres.on('click', function () { | $deselUnres.on('click', function () { | ||
$list.find('.ocr-person-unresolved .ocr-include').prop('checked', false) | $list.find('.ocr-person-unresolved .ocr-include').prop('checked', false).trigger('change'); | ||
refreshPreview(); | refreshPreview(); | ||
}); | }); | ||
| Line 1,881: | Line 2,611: | ||
} | } | ||
$facts.append($list); | $facts.append($list); | ||
/* Advanced: OCR text, doc type, gallery, overwrite, rebuild */ | /* Advanced: OCR text, doc type, gallery, overwrite, rebuild */ | ||
| Line 1,913: | Line 2,642: | ||
var $overwrite = $('<label>').append( | var $overwrite = $('<label>').append( | ||
$('<input>', { type: 'checkbox', checked: | $('<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 | $('<input>', { type: 'checkbox', checked: mwApiBool(job.add_to_gallery) }), | ||
' Add document to Gallery on Apply' + (job | ' 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 | 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 | var $rebuild = $('<button>', { type: 'button', text: 'Rebuild facts / re-OCR if handwriting checked' }); | ||
$advBody.append($('<div>', { 'class': 'ocr-advanced-actions' }).append($save, $rebuild)); | $advBody.append($('<div>', { 'class': 'ocr-advanced-actions' }).append($save, $rebuild)); | ||
$adv.append($advBody); | $adv.append($advBody); | ||
| Line 1,943: | Line 2,695: | ||
var $actions = $('<div>', { 'class': 'ocr-review-actions' }); | var $actions = $('<div>', { 'class': 'ocr-review-actions' }); | ||
var $discard = $('<button>', { type: 'button', text: 'Discard' }); | var $discard = $('<button>', { type: 'button', text: 'Discard' }); | ||
var $dry = $('<button>', { type: 'button', text: 'Preview Apply' }); | |||
var $apply = $('<button>', { | var $apply = $('<button>', { | ||
type: 'button', | type: 'button', | ||
'class': 'ocr-apply-btn', | 'class': 'ocr-apply-btn', | ||
text | text: 'Apply selected' | ||
}); | }); | ||
var $status = $('<p>', { 'class': 'ocr-review-status' }); | var $status = $('<p>', { 'class': 'ocr-review-status' }); | ||
if (!cfg.canApply) { | if (!cfg.canApply) { | ||
$apply.prop('disabled', true); | $apply.prop('disabled', true); | ||
$dry.prop('disabled', true); | |||
$status.text('Apply requires the ocr-reviewer group (or admin). Your upload stays in the review queue.'); | $status.text('Apply requires the ocr-reviewer group (or admin). Your upload stays in the review queue.'); | ||
} | } | ||
$actions.append($discard, $apply, $status); | $actions.append($discard, $dry, $apply, $status); | ||
$panel.append($actions); | $panel.append($actions); | ||
$host.append($panel); | $host.append($panel); | ||
| Line 1,959: | Line 2,713: | ||
function collectProposed() { | function collectProposed() { | ||
var applyNotesOn = !!$applyNotes.find('input').prop('checked'); | |||
var out = []; | var out = []; | ||
$list.find('.ocr-fact-row').each(function () { | $list.find('.ocr-fact-row').each(function () { | ||
| Line 1,974: | Line 2,729: | ||
var $cb = $row.find('input[type="checkbox"]').first(); | var $cb = $row.find('input[type="checkbox"]').first(); | ||
base.selected = $cb.length ? !!$cb.prop('checked') : false; | base.selected = $cb.length ? !!$cb.prop('checked') : false; | ||
if ( | if (base.kind === 'note' && !applyNotesOn) { | ||
base.selected = false; | base.selected = false; | ||
} | } | ||
| Line 2,012: | Line 2,767: | ||
doc_type: $docSelect.val(), | doc_type: $docSelect.val(), | ||
gallery_caption: $.trim($caption.val() || ''), | gallery_caption: $.trim($caption.val() || ''), | ||
force_identity: $forceId.find('input').prop('checked') | force_identity: $forceId.find('input').prop('checked'), | ||
force_multi: $forceMulti.find('input').prop('checked'), | |||
force_vision: $forceVision.find('input').prop('checked'), | |||
apply_notes: $applyNotes.find('input').prop('checked') | |||
}; | }; | ||
} | } | ||
| Line 2,063: | Line 2,821: | ||
ocrPost({ op: 'discard', job_id: job.id }).then(function () { | ocrPost({ op: 'discard', job_id: job.id }).then(function () { | ||
$host.empty().append($('<p>').text('Discarded.')); | $host.empty().append($('<p>').text('Discarded.')); | ||
}); | |||
}); | |||
$dry.on('click', function () { | |||
$status.text('Building Apply preview…'); | |||
$dry.prop('disabled', true); | |||
ocrPost({ | |||
op: 'dryrun', | |||
job_id: job.id, | |||
payload: JSON.stringify(payload()) | |||
}).then(function (resp) { | |||
$dry.prop('disabled', false); | |||
var plan = resp.plan || {}; | |||
var pages = plan.pages || []; | |||
var $box = $('<div>', { 'class': 'ocr-dry-run' }); | |||
$box.append($('<strong>').text( | |||
'Apply preview — would edit ' + (plan.will_edit || 0) + | |||
' of ' + (plan.page_count || 0) + ' page(s)' | |||
)); | |||
var $ul = $('<ul>'); | |||
pages.forEach(function (pg) { | |||
var $li = $('<li>'); | |||
$li.append($('<a>', { | |||
href: mw.util.getUrl(pg.title), | |||
text: pg.title, | |||
target: '_blank' | |||
})); | |||
$li.append(document.createTextNode(' — ' + (pg.status || 'planned'))); | |||
if (pg.changes && pg.changes.length) { | |||
var $sub = $('<ul>'); | |||
pg.changes.forEach(function (c) { | |||
$sub.append($('<li>').text(c)); | |||
}); | |||
$li.append($sub); | |||
} | |||
$ul.append($li); | |||
}); | |||
$box.append($ul); | |||
$status.empty().append($box); | |||
}).catch(function (err) { | |||
$dry.prop('disabled', false); | |||
$status.text(String(err && err.error && err.error.info || err)); | |||
}); | }); | ||
}); | }); | ||
$apply.on('click', function () { | $apply.on('click', function () { | ||
if (!job.identity_ok && !$forceId.find('input').prop('checked')) { | if (!isMultiPersonJob(job) && !mwApiBool(job.identity_ok) && !$forceId.find('input').prop('checked')) { | ||
$status.text('Confirm identity (Advanced → checkbox) before Apply, or fix the wrong document.'); | $status.text('Confirm identity (Advanced → checkbox) before Apply, or fix the wrong document.'); | ||
$adv.prop('open', true); | $adv.prop('open', true); | ||
| Line 2,073: | Line 2,873: | ||
} | } | ||
var selected = collectProposed().filter(function (p) { | var selected = collectProposed().filter(function (p) { | ||
return p.selected && isWritableFact(p) && (p.target_title || '').trim(); | return p.selected && isWritableFact(p, $applyNotes.find('input').prop('checked')) && (p.target_title || '').trim(); | ||
}); | }); | ||
if (!selected.length) { | if (!selected.length) { | ||
$status.text('Select at least one change with a matched soldier.'); | $status.text('Select at least one change with a matched soldier.'); | ||
return; | |||
} | |||
var nPages = {}; | |||
selected.forEach(function (p) { nPages[p.target_title] = 1; }); | |||
var pageCount = Object.keys(nPages).length; | |||
if (pageCount > 5 && !window.confirm('Apply changes to ' + pageCount + ' soldier pages?')) { | |||
return; | return; | ||
} | } | ||
| Line 2,153: | Line 2,959: | ||
} | } | ||
function startUpload($fileInput, boundTitle, $host, cfg) { | function startUpload($fileInput, boundTitle, $host, cfg, opts) { | ||
opts = opts || {}; | |||
var file = $fileInput[0].files[0]; | var file = $fileInput[0].files[0]; | ||
if (!file) { | if (!file) { | ||
| Line 2,164: | Line 2,971: | ||
op: 'upload', | op: 'upload', | ||
bound_title: boundTitle || '', | bound_title: boundTitle || '', | ||
force_multi: opts.forceMulti ? true : false, | |||
force_vision: opts.forceVision ? true : false, | |||
file: file, | file: file, | ||
format: 'json' | format: 'json' | ||
| Line 2,210: | Line 3,019: | ||
var $section = $('<div>', { 'class': 'soldier-contrib-section soldier-contrib-ocr' }); | var $section = $('<div>', { 'class': 'soldier-contrib-section soldier-contrib-ocr' }); | ||
$section.append($('<p>', { 'class': 'soldier-contrib-heading' }).append( | $section.append($('<p>', { 'class': 'soldier-contrib-heading' }).append( | ||
$('<strong>').text('OCR a document') | $('<strong>').text('OCR a document to enrich this profile') | ||
)); | )); | ||
$section.append($('<p>', { 'class': 'soldier-contrib-help' }).text( | $section.append($('<p>', { 'class': 'soldier-contrib-help' }).text( | ||
'Scan | 'Scan a form, letter, certificate, clipping, or roster. Review proposes infobox facts and a short biography draft — nothing is written until Apply. Team rosters can update many soldiers at once.' | ||
)); | )); | ||
var $file = $('<input>', { | var $file = $('<input>', { | ||
| Line 2,220: | Line 3,029: | ||
'class': 'soldier-upload-file' | 'class': 'soldier-upload-file' | ||
}); | }); | ||
var $rosterMode = $('<label>', { 'class': 'ocr-force-multi-upload' }).append( | |||
$('<input>', { type: 'checkbox' }), | |||
' This is a multi-person roster (not only this soldier)' | |||
); | |||
var $visionMode = $('<label>', { 'class': 'ocr-force-multi-upload' }).append( | |||
$('<input>', { type: 'checkbox' }), | |||
' Handwriting / hard scan (free print OCR first; vision only if needed)' | |||
); | |||
var $btn = $('<button>', { type: 'button', text: 'Upload & OCR' }); | var $btn = $('<button>', { type: 'button', text: 'Upload & OCR' }); | ||
var $host = $('<div>', { 'class': 'ocr-review-host' }); | var $host = $('<div>', { 'class': 'ocr-review-host' }); | ||
$section.append($file, $btn, $host); | $section.append($file, $rosterMode, $visionMode, $btn, $host); | ||
$btn.on('click', function () { | $btn.on('click', function () { | ||
startUpload($file, pageTitle, $host, cfg); | startUpload($file, pageTitle, $host, cfg, { | ||
forceMulti: $rosterMode.find('input').prop('checked'), | |||
forceVision: $visionMode.find('input').prop('checked') | |||
}); | |||
}); | }); | ||
var $slot = $('.soldier-ocr-slot').first(); | var $slot = $('.soldier-ocr-slot').first(); | ||
| Line 2,246: | Line 3,066: | ||
$up.append($('<h3>').text('Upload a document')); | $up.append($('<h3>').text('Upload a document')); | ||
$up.append($('<p>').text( | $up.append($('<p>').text( | ||
' | 'Enrich soldier profiles from scans: propose infobox facts, a short biography draft, and Sources. For multi-person IPW rosters, review is a table (usually Team membership). Rank, role, and ASN are matching cues until you approve.' | ||
)); | )); | ||
var $file2 = $('<input>', { type: 'file', accept: '.jpg,.jpeg,.png,.gif,.webp,.tif,.tiff,.pdf' }); | var $file2 = $('<input>', { type: 'file', accept: '.jpg,.jpeg,.png,.gif,.webp,.tif,.tiff,.pdf' }); | ||
var $vision2 = $('<label>', { 'class': 'ocr-force-multi-upload' }).append( | |||
$('<input>', { type: 'checkbox' }), | |||
' Handwriting / hard scan (vision only if print OCR is weak)' | |||
); | |||
var $btn2 = $('<button>', { type: 'button', text: 'Upload & OCR' }); | var $btn2 = $('<button>', { type: 'button', text: 'Upload & OCR' }); | ||
var $host2 = $('<div>', { 'class': 'ocr-review-host' }); | var $host2 = $('<div>', { 'class': 'ocr-review-host' }); | ||
$up.append($file2, ' ', $btn2, $host2); | $up.append($file2, $vision2, ' ', $btn2, $host2); | ||
$btn2.on('click', function () { | $btn2.on('click', function () { | ||
startUpload($file2, '', $host2, cfg); | startUpload($file2, '', $host2, cfg, { | ||
forceVision: $vision2.find('input').prop('checked') | |||
}); | |||
}); | }); | ||
$slot.append($up); | $slot.append($up); | ||
| Line 2,262: | Line 3,088: | ||
'Resume ready jobs, or reopen a profile upload that finished after you left the page.' | 'Resume ready jobs, or reopen a profile upload that finished after you left the page.' | ||
)); | )); | ||
var $ul = $('<ul>', { 'class': 'ocr-queue-list' }); | var me = mw.config.get('wgUserName') || ''; | ||
$queue.append($ul); | var $filters = $('<div>', { 'class': 'ocr-queue-filters' }); | ||
var $filterMine = $('<label>').append( | |||
$('<input>', { type: 'checkbox', checked: false, 'class': 'ocr-queue-mine' }), | |||
' Only mine' | |||
); | |||
var $filterReady = $('<label>').append( | |||
$('<input>', { type: 'checkbox', checked: true, 'class': 'ocr-queue-ready' }), | |||
' Ready' | |||
); | |||
var $filterProc = $('<label>').append( | |||
$('<input>', { type: 'checkbox', checked: true, 'class': 'ocr-queue-processing' }), | |||
' In progress' | |||
); | |||
$filters.append($filterMine, ' ', $filterReady, ' ', $filterProc); | |||
var $clearRow = $('<div>', { 'class': 'ocr-queue-clear' }); | |||
var $clearStatus = $('<span>', { 'class': 'ocr-fact-meta' }); | |||
var $clearReady = $('<button>', { | |||
type: 'button', | |||
text: 'Clear ready queue', | |||
title: 'Discard all ready jobs without opening them' | |||
}); | |||
if (!cfg.canApply) { | |||
$clearReady.prop('disabled', true); | |||
$clearStatus.text('Clear queue requires ocr-reviewer or admin.'); | |||
} | |||
$clearRow.append($clearReady, ' ', $clearStatus); | |||
var $ul = $('<ul>', { 'class': 'ocr-queue-list' }); | |||
$queue.append($filters, $clearRow, $ul); | |||
$slot.append($queue); | $slot.append($queue); | ||
| Line 2,276: | Line 3,129: | ||
}).then(function (bags) { | }).then(function (bags) { | ||
$ul.empty(); | $ul.empty(); | ||
var jobs = [].concat(bags.processing || [] | 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 | $ul.append($('<li>').text('No jobs match these filters.')); | ||
return; | return; | ||
} | } | ||
| Line 2,294: | Line 3,171: | ||
var $meta = $('<div>', { 'class': 'ocr-queue-meta' }); | var $meta = $('<div>', { 'class': 'ocr-queue-meta' }); | ||
var ps = job.people_summary || {}; | var ps = job.people_summary || {}; | ||
var peopleBit = job | var peopleBit = isMultiPersonJob(job) | ||
? ((ps.detected || 0) + ' people · ' + (ps.matched || 0) + ' matched · ' + (ps.unresolved || 0) + ' unmatched') | ? ((ps.detected || 0) + ' people · ' + (ps.matched || 0) + ' matched · ' + (ps.unresolved || 0) + ' unmatched') | ||
: (job.bound_title ? job.bound_title : 'single document'); | : (job.bound_title ? job.bound_title : 'single document'); | ||
var mineBit = (job.uploader || '') === me ? ' · you' : ''; | |||
var kindBit = job.source_kind === 'facebook_seed' | |||
? ' · FB seed' + (job.source_ref ? (' #' + job.source_ref) : '') | |||
: ''; | |||
$meta.append($('<div>').append( | $meta.append($('<div>').append( | ||
$('<strong>').text(job.filename || job.id), | $('<strong>').text(job.filename || job.id), | ||
document.createTextNode(' · ' + (job.doc_type_label || job.doc_type || 'document')) | document.createTextNode( | ||
' · ' + (job.doc_type_label || job.doc_type || 'document') + kindBit | |||
) | |||
)); | )); | ||
$meta.append($('<div>', { 'class': 'ocr-fact-meta' }).text( | $meta.append($('<div>', { 'class': 'ocr-fact-meta' }).text( | ||
peopleBit + ' · ' + (job.status || '?') + | peopleBit + ' · ' + (job.status || '?') + | ||
(job.status === 'processing' ? (' ' + (job.progress || 0) + '%') : '') + | (job.status === 'processing' ? (' ' + (job.progress || 0) + '%') : '') + | ||
' · by ' + (job.uploader || '?') | ' · by ' + (job.uploader || '?') + mineBit + | ||
' · ' + timeAgo(job.created_at) | |||
)); | )); | ||
if (job.apply_progress && job.apply_progress.length) { | |||
$meta.append($('<div>', { 'class': 'ocr-fact-meta' }).text( | |||
'Partial apply: ' + job.apply_progress.length + ' page(s) already saved — Open and Apply to resume' | |||
)); | |||
} | |||
var $open = $('<button>', { type: 'button', text: 'Open' }); | var $open = $('<button>', { type: 'button', text: 'Open' }); | ||
$meta.append($open); | $meta.append($open); | ||
| Line 2,325: | Line 3,214: | ||
}); | }); | ||
} | } | ||
$filters.find('input').on('change', loadQueue); | |||
$clearReady.on('click', function () { | |||
if (!cfg.canApply) { | |||
return; | |||
} | |||
var n = Number($clearReady.data('readyCount') || 0); | |||
var mineOnly = !!$clearReady.data('mineOnly'); | |||
if (!n) { | |||
$clearStatus.text('No ready jobs to clear.'); | |||
return; | |||
} | |||
var msg = mineOnly | |||
? ('Discard your ' + n + ' ready OCR job(s)? This cannot be undone.') | |||
: ('Discard all ' + n + ' ready OCR job(s)? This cannot be undone.'); | |||
if (!window.confirm(msg)) { | |||
return; | |||
} | |||
$clearReady.prop('disabled', true); | |||
$clearStatus.text('Clearing…'); | |||
ocrPost({ | |||
op: 'clearqueue', | |||
payload: JSON.stringify({ | |||
statuses: 'ready', | |||
mine_only: mineOnly, | |||
limit: 500 | |||
}) | |||
}).then(function (resp) { | |||
var cleared = (resp && resp.cleared && resp.cleared.discarded) || 0; | |||
$clearStatus.text('Discarded ' + cleared + ' job(s).'); | |||
$host2.empty(); | |||
loadQueue(); | |||
}).catch(function (err) { | |||
$clearStatus.text(String(err && err.error && err.error.info || err)); | |||
}).always(function () { | |||
$clearReady.prop('disabled', false); | |||
}); | |||
}); | |||
loadQueue(); | loadQueue(); | ||
} | } | ||
| Line 2,331: | Line 3,257: | ||
}); | }); | ||
}); | }); | ||
(function rbLiveTeammates() { | |||
var $rails = $('.mw-parser-output .soldier-related[data-current-title]'); | |||
if (!$rails.length || !mw.Api) { | |||
return; | |||
} | |||
function titleToCaption(title) { | |||
var m = String(title || '').match(/^([^,]+),\s*(.+?)\s*\(/); | |||
if (m) { | |||
return $.trim(m[2] + ' ' + m[1]); | |||
} | |||
return String(title || ''); | |||
} | |||
function wikiLink(title, caption) { | |||
return $('<a></a>') | |||
.attr('href', mw.util.getUrl(title)) | |||
.attr('title', title) | |||
.text(caption || titleToCaption(title)); | |||
} | |||
function fetchCategoryMembers(catTitle) { | |||
var titles = []; | |||
function next(cont) { | |||
var params = { | |||
action: 'query', | |||
list: 'categorymembers', | |||
cmtitle: catTitle, | |||
cmlimit: 'max', | |||
cmnamespace: 0, | |||
cmtype: 'page' | |||
}; | |||
if (cont) { | |||
params.cmcontinue = cont; | |||
} | |||
return new mw.Api().get(params).then(function (data) { | |||
var members = (data.query && data.query.categorymembers) || []; | |||
members.forEach(function (m) { | |||
if (m.title && !rbIsMergedDuplicateTitle(m.title)) { | |||
titles.push(m.title); | |||
} | |||
}); | |||
var more = data.continue && data.continue.cmcontinue; | |||
if (more) { | |||
return next(more); | |||
} | |||
return titles; | |||
}); | |||
} | |||
return next(null); | |||
} | |||
$rails.each(function () { | |||
var $rail = $(this); | |||
if ($rail.data('rbLiveTeammates')) { | |||
return; | |||
} | |||
$rail.data('rbLiveTeammates', 1); | |||
var current = String($rail.attr('data-current-title') || mw.config.get('wgPageName') || '') | |||
.replace(/_/g, ' '); | |||
$rail.find('.soldier-related-block[data-team-cat]').each(function () { | |||
var $block = $(this); | |||
var cat = String($block.attr('data-team-cat') || ''); | |||
if (!/^Category:Team /.test(cat)) { | |||
return; | |||
} | |||
var $links = $block.find('.soldier-related-links-live').first(); | |||
if (!$links.length) { | |||
return; | |||
} | |||
$links.text('Loading teammates…'); | |||
fetchCategoryMembers(cat).done(function (titles) { | |||
var others = titles.filter(function (t) { | |||
return t.replace(/_/g, ' ') !== current; | |||
}); | |||
others.sort(function (a, b) { | |||
return titleToCaption(a).localeCompare(titleToCaption(b)); | |||
}); | |||
$links.empty(); | |||
if (!others.length) { | |||
$links.append(document.createTextNode('No other teammates listed yet · ')); | |||
$links.append(wikiLink(cat, 'full roster')); | |||
return; | |||
} | |||
var max = 24; | |||
others.slice(0, max).forEach(function (title, i) { | |||
if (i > 0) { | |||
$links.append(document.createTextNode(' · ')); | |||
} | |||
$links.append(wikiLink(title, titleToCaption(title))); | |||
}); | |||
$links.append(document.createTextNode(' · ')); | |||
$links.append(wikiLink(cat, others.length > max ? 'full roster (' + others.length + ')' : 'full roster')); | |||
}).fail(function () { | |||
$links.empty().append(wikiLink(cat, 'Browse team roster')); | |||
}); | |||
}); | |||
}); | |||
}()); | |||
$(function rbCampManualUpload() { | |||
var $slot = $('.mw-parser-output .camp-manual-upload-slot').first(); | |||
if (!$slot.length || $slot.data('rbCampManualUpload')) { | |||
return; | |||
} | |||
$slot.data('rbCampManualUpload', 1); | |||
var LIBRARY = 'Training materials/Library'; | |||
var PLACEHOLDER = "''No manuals listed yet. Upload a PDF above and it will appear here.''"; | |||
var DOC_EXT = /\.(pdf|docx?|txt|rtf|odt|tiff?|jpe?g|png|gif|webp)$/i; | |||
var pageTitle = mw.config.get('wgPageName'); | |||
var loggedIn = !!mw.config.get('wgUserName'); | |||
var uploadUrl = mw.util.getUrl('Special:Upload'); | |||
$slot.empty(); | |||
var $box = $('<div>', { 'class': 'camp-manual-upload-box soldier-upload-body' }); | |||
$box.append($('<p>', { 'class': 'soldier-contrib-help' }).append( | |||
document.createTextNode('Upload a manual or related Camp Ritchie document (PDF preferred). Also '), | |||
$('<a>', { href: uploadUrl, text: 'Special:Upload' }), | |||
document.createTextNode('.') | |||
)); | |||
if (!loggedIn) { | |||
$box.append($('<p>', { 'class': 'soldier-upload-login' }).html( | |||
'<a href="' + mw.util.getUrl('Special:UserLogin', { returnto: pageTitle }) + '">Log in</a> to upload files.' | |||
)); | |||
$slot.append($box); | |||
return; | |||
} | |||
var $form = $('<form>', { 'class': 'soldier-upload-form camp-manual-upload-form' }); | |||
var $file = $('<input>', { | |||
type: 'file', | |||
name: 'campManualFile', | |||
'class': 'soldier-upload-file', | |||
accept: '.pdf,.doc,.docx,.txt,.rtf,.odt,.tif,.tiff,.jpg,.jpeg,.png' | |||
}); | |||
var $label = $('<label>').append( | |||
document.createTextNode('Display title (optional)'), | |||
$('<input>', { | |||
type: 'text', | |||
name: 'campManualTitle', | |||
'class': 'camp-manual-title', | |||
placeholder: 'e.g. Interrogation manual, 1943', | |||
maxlength: 200 | |||
}) | |||
); | |||
var $note = $('<label>').append( | |||
document.createTextNode('Short note / source (optional)'), | |||
$('<input>', { | |||
type: 'text', | |||
name: 'campManualNote', | |||
'class': 'camp-manual-note', | |||
placeholder: 'e.g. NARA / museum scan', | |||
maxlength: 300 | |||
}) | |||
); | |||
var $status = $('<p>', { 'class': 'soldier-upload-status', text: '' }); | |||
var $progress = $('<div>', { 'class': 'camp-manual-progress', hidden: true }); | |||
var $progressFill = $('<div>', { 'class': 'camp-manual-progress-fill' }); | |||
var $progressBar = $('<div>', { | |||
'class': 'camp-manual-progress-bar', | |||
role: 'progressbar', | |||
'aria-valuemin': 0, | |||
'aria-valuemax': 100, | |||
'aria-valuenow': 0 | |||
}).append($progressFill); | |||
var $progressLabel = $('<p>', { 'class': 'camp-manual-progress-label', text: '' }); | |||
$progress.append($progressBar, $progressLabel); | |||
var $submit = $('<button>', { type: 'submit', text: 'Upload to library' }); | |||
$form.append($file, $label, $note, $submit, $progress, $status); | |||
$box.append($form); | |||
$slot.append($box); | |||
function setStatus(msg, isError) { | |||
$status.text(msg).css('color', isError ? '#ba0000' : '#202122'); | |||
} | |||
function formatBytes(n) { | |||
if (n >= 1024 * 1024) { | |||
return (n / (1024 * 1024)).toFixed(1) + ' MB'; | |||
} | |||
if (n >= 1024) { | |||
return Math.round(n / 1024) + ' KB'; | |||
} | |||
return n + ' B'; | |||
} | |||
function setProgress(loaded, total) { | |||
$progress.prop('hidden', false); | |||
if (!total) { | |||
$progress.addClass('is-indeterminate'); | |||
$progressBar.attr('aria-valuenow', 0); | |||
$progressFill.css('width', '30%'); | |||
$progressLabel.text('Uploading…'); | |||
return; | |||
} | |||
$progress.removeClass('is-indeterminate'); | |||
var pct = Math.max(0, Math.min(100, Math.round((loaded / total) * 100))); | |||
$progressBar.attr('aria-valuenow', pct); | |||
$progressFill.css('width', pct + '%'); | |||
$progressLabel.text(pct + '% · ' + formatBytes(loaded) + ' of ' + formatBytes(total)); | |||
} | |||
function resetProgress() { | |||
$progress.prop('hidden', true).removeClass('is-indeterminate'); | |||
$progressFill.css('width', '0%'); | |||
$progressBar.attr('aria-valuenow', 0); | |||
$progressLabel.text(''); | |||
} | |||
function escapePipe(s) { | |||
return String(s || '').replace(/\|/g, '–').replace(/\[\[/g, '').replace(/\]\]/g, ''); | |||
} | |||
function buildBullet(filename, displayTitle, note) { | |||
var label = escapePipe(displayTitle || filename.replace(/_/g, ' ')); | |||
var line = '* [[Media:' + filename + '|' + label + ']]'; | |||
if (note) { | |||
line += ' — ' + escapePipe(note); | |||
} | |||
return line; | |||
} | |||
function appendToLibrary(api, filename, displayTitle, note) { | |||
var bullet = buildBullet(filename, displayTitle, note); | |||
return api.get({ | |||
action: 'query', | |||
prop: 'revisions|info', | |||
titles: LIBRARY, | |||
rvprop: 'content|timestamp', | |||
rvslots: 'main', | |||
curtimestamp: 1, | |||
formatversion: 2, | |||
maxage: 0, | |||
smaxage: 0 | |||
}).then(function (data) { | |||
var page = data.query && data.query.pages && data.query.pages[0]; | |||
var rev = page && page.revisions && page.revisions[0]; | |||
var text = (rev && rev.slots && rev.slots.main && rev.slots.main.content) || ''; | |||
var basets = rev && rev.timestamp; | |||
var startts = data.curtimestamp; | |||
var next; | |||
var trimmed = $.trim(text); | |||
var already = trimmed.indexOf('[[Media:' + filename + '|') !== -1 || | |||
trimmed.indexOf('[[File:' + filename + '|') !== -1; | |||
if (already) { | |||
return purgeLibraryPages(api).then(function () { | |||
return { edit: { result: 'Success', nochange: true } }; | |||
}); | |||
} | |||
if (!trimmed || trimmed.indexOf(PLACEHOLDER) !== -1) { | |||
next = bullet + '\n'; | |||
} else { | |||
next = trimmed + '\n' + bullet + '\n'; | |||
} | |||
var params = { | |||
action: 'edit', | |||
title: LIBRARY, | |||
text: next, | |||
summary: 'Add training material: ' + filename, | |||
format: 'json' | |||
}; | |||
if (basets) { | |||
params.basetimestamp = basets; | |||
} | |||
if (startts) { | |||
params.starttimestamp = startts; | |||
} | |||
return api.postWithToken('csrf', params).then(function (editData) { | |||
if (editData.edit && editData.edit.result === 'Success') { | |||
return purgeLibraryPages(api).then(function () { | |||
return editData; | |||
}); | |||
} | |||
if (editData.error && editData.error.code === 'editconflict') { | |||
return appendToLibrary(api, filename, displayTitle, note); | |||
} | |||
throw new Error((editData.error && editData.error.info) || 'Could not update library list'); | |||
}); | |||
}); | |||
} | |||
function failMessage(code, data) { | |||
if (data && data.error && data.error.info) { | |||
return data.error.info; | |||
} | |||
if (data && data.exception && data.exception.message) { | |||
return data.exception.message; | |||
} | |||
if (code && typeof code === 'object' && code.message) { | |||
return code.message; | |||
} | |||
if (typeof code === 'string' && code && code !== 'http' && code !== 'error' && code !== 'timeout') { | |||
return code; | |||
} | |||
if (data && data.xhr && data.xhr.status) { | |||
return 'Upload failed (HTTP ' + data.xhr.status + '). Try again, or use Special:Upload.'; | |||
} | |||
return 'Upload failed (connection dropped or timed out). Try again, or use Special:Upload.'; | |||
} | |||
function purgeLibraryPages(api) { | |||
return api.postWithToken('csrf', { | |||
action: 'purge', | |||
titles: 'Training materials|' + LIBRARY, | |||
forcelinkupdate: 1, | |||
format: 'json' | |||
}); | |||
} | |||
function readLibraryText(api) { | |||
return api.get({ | |||
action: 'query', | |||
prop: 'revisions|info', | |||
titles: LIBRARY, | |||
rvprop: 'content|timestamp', | |||
rvslots: 'main', | |||
curtimestamp: 1, | |||
formatversion: 2, | |||
maxage: 0, | |||
smaxage: 0 | |||
}).then(function (data) { | |||
var page = data.query && data.query.pages && data.query.pages[0]; | |||
var rev = page && page.revisions && page.revisions[0]; | |||
return { | |||
text: (rev && rev.slots && rev.slots.main && rev.slots.main.content) || '', | |||
basetimestamp: rev && rev.timestamp, | |||
starttimestamp: data.curtimestamp | |||
}; | |||
}); | |||
} | |||
function writeLibraryText(api, next, summary, basets, startts) { | |||
var params = { | |||
action: 'edit', | |||
title: LIBRARY, | |||
text: next, | |||
summary: summary, | |||
format: 'json' | |||
}; | |||
if (basets) { | |||
params.basetimestamp = basets; | |||
} | |||
if (startts) { | |||
params.starttimestamp = startts; | |||
} | |||
return api.postWithToken('csrf', params).then(function (editData) { | |||
if (editData.edit && editData.edit.result === 'Success') { | |||
return purgeLibraryPages(api).then(function () { | |||
return editData; | |||
}); | |||
} | |||
if (editData.error && editData.error.code === 'editconflict') { | |||
throw new Error('editconflict'); | |||
} | |||
throw new Error((editData.error && editData.error.info) || 'Could not update library list'); | |||
}); | |||
} | |||
function removeFromLibrary(api, filename) { | |||
return readLibraryText(api).then(function (cur) { | |||
var lines = String(cur.text || '').split(/\r?\n/); | |||
var nextLines = lines.filter(function (line) { | |||
return line.indexOf('[[Media:' + filename + '|') === -1 && | |||
line.indexOf('[[File:' + filename + '|') === -1 && | |||
line.indexOf(filename) === -1; | |||
}); | |||
var next = $.trim(nextLines.join('\n')); | |||
if (!next) { | |||
next = PLACEHOLDER; | |||
} else { | |||
next = next + '\n'; | |||
} | |||
return writeLibraryText( | |||
api, | |||
next, | |||
'Remove from Training materials library: ' + filename, | |||
cur.basetimestamp, | |||
cur.starttimestamp | |||
).then(null, function (err) { | |||
if (String(err && err.message || err) === 'editconflict') { | |||
return removeFromLibrary(api, filename); | |||
} | |||
throw err; | |||
}); | |||
}); | |||
} | |||
function deleteLibraryFile(api, filename) { | |||
return api.postWithToken('csrf', { | |||
action: 'delete', | |||
title: 'File:' + filename, | |||
reason: 'Removed from Training materials library', | |||
format: 'json' | |||
}).then(function (data) { | |||
if (data.error) { | |||
throw new Error(data.error.info || 'Could not delete file'); | |||
} | |||
return removeFromLibrary(api, filename); | |||
}); | |||
} | |||
function filenameFromHref(href, titleAttr) { | |||
if (titleAttr && /\.[a-z0-9]{2,5}$/i.test(titleAttr)) { | |||
return String(titleAttr).replace(/ /g, '_'); | |||
} | |||
if (!href) { | |||
return ''; | |||
} | |||
try { | |||
href = decodeURIComponent(href); | |||
} catch (e) { /* ignore */ } | |||
var m = href.match(/\/wiki\/(?:File|Media):([^?#]+)/i) || | |||
href.match(/[?&]title=(?:File|Media):([^&#]+)/i) || | |||
href.match(/\/images\/(?:thumb\/)?[^/]+\/[^/]+\/([^/?#]+)$/i); | |||
if (!m) { | |||
return ''; | |||
} | |||
return m[1].replace(/_/g, ' ').trim().replace(/ /g, '_'); | |||
} | |||
function enhanceLibraryManage() { | |||
if (!mw.config.get('wgUserName')) { | |||
return; | |||
} | |||
var $heading = $('.mw-parser-output .mw-heading, .mw-parser-output h2').filter(function () { | |||
return /^Library\b/i.test($(this).text().replace(/\s*\[.*$/, '').trim()); | |||
}).first(); | |||
if (!$heading.length) { | |||
return; | |||
} | |||
var $list = $heading.nextAll('ul').first(); | |||
if (!$list.length) { | |||
$list = $heading.parent().nextAll('ul').first(); | |||
} | |||
if (!$list.length || $list.data('rbCampManualManage')) { | |||
return; | |||
} | |||
$list.data('rbCampManualManage', 1); | |||
var groups = mw.config.get('wgUserGroups') || []; | |||
var canDeleteFile = groups.indexOf('sysop') !== -1; | |||
var api = new mw.Api(); | |||
$list.children('li').each(function () { | |||
var $li = $(this); | |||
if ($li.find('.camp-manual-lib-actions').length) { | |||
return; | |||
} | |||
var $a = $li.find('a.internal, a[href*="/images/"], a[href*="File:"], a[href*="Media:"]').first(); | |||
if (!$a.length) { | |||
$a = $li.find('a[href]').first(); | |||
} | |||
var filename = filenameFromHref($a.attr('href'), $a.attr('title')); | |||
if (!filename) { | |||
return; | |||
} | |||
var $actions = $('<span>', { 'class': 'camp-manual-lib-actions' }); | |||
var $remove = $('<button>', { | |||
type: 'button', | |||
'class': 'camp-manual-lib-remove', | |||
text: 'Remove' | |||
}).attr('title', 'Remove from this list (file stays on the wiki)'); | |||
$actions.append($remove); | |||
if (canDeleteFile) { | |||
$actions.append($('<button>', { | |||
type: 'button', | |||
'class': 'camp-manual-lib-delete', | |||
text: 'Delete file' | |||
}).attr('title', 'Delete the file from the wiki (administrators only)')); | |||
} | |||
$li.append($actions); | |||
$remove.on('click', function () { | |||
if (!window.confirm('Remove “' + filename.replace(/_/g, ' ') + '” from the Library list?\n(The file will remain on the wiki.)')) { | |||
return; | |||
} | |||
$actions.find('button').prop('disabled', true); | |||
removeFromLibrary(api, filename).done(function () { | |||
window.location.reload(); | |||
}).fail(function (code, data) { | |||
window.alert(failMessage(code, data)); | |||
$actions.find('button').prop('disabled', false); | |||
}); | |||
}); | |||
$actions.on('click', '.camp-manual-lib-delete', function () { | |||
if (!window.confirm('Permanently delete file “' + filename.replace(/_/g, ' ') + '” from the wiki?')) { | |||
return; | |||
} | |||
$actions.find('button').prop('disabled', true); | |||
deleteLibraryFile(api, filename).done(function () { | |||
window.location.reload(); | |||
}).fail(function (code, data) { | |||
window.alert(failMessage(code, data)); | |||
$actions.find('button').prop('disabled', false); | |||
}); | |||
}); | |||
}); | |||
} | |||
enhanceLibraryManage(); | |||
$form.on('submit', function (evt) { | |||
evt.preventDefault(); | |||
var file = $file[0].files[0]; | |||
if (!file) { | |||
setStatus('Choose a file first.', true); | |||
return; | |||
} | |||
if (!DOC_EXT.test(file.name)) { | |||
setStatus('File type not allowed (PDF, Office docs, images, TIFF).', true); | |||
return; | |||
} | |||
var maxBytes = 120 * 1024 * 1024; | |||
if (file.size > maxBytes) { | |||
setStatus('File is too large (max about 120 MB). Split the PDF or compress it, then try again.', true); | |||
return; | |||
} | |||
var displayTitle = $.trim($form.find('.camp-manual-title').val() || ''); | |||
var note = $.trim($form.find('.camp-manual-note').val() || ''); | |||
$submit.prop('disabled', true); | |||
resetProgress(); | |||
setProgress(0, file.size); | |||
setStatus('Uploading…'); | |||
var api = new mw.Api({ ajax: { timeout: 0 } }); | |||
var fileText = 'Camp Ritchie / MITC training material.'; | |||
var uploadedName = null; | |||
var uploadParams = { | |||
filename: file.name, | |||
comment: 'Uploaded to Training materials library', | |||
text: fileText, | |||
ignorewarnings: true | |||
}; | |||
function doUpload() { | |||
/* Multipart + XHR progress (api.upload alone often has no progress events). */ | |||
return api.postWithToken('csrf', $.extend({ | |||
action: 'upload', | |||
file: file, | |||
format: 'json' | |||
}, uploadParams), { | |||
contentType: 'multipart/form-data', | |||
timeout: 0, | |||
xhr: function () { | |||
var xhr = $.ajaxSettings.xhr(); | |||
if (xhr.upload) { | |||
xhr.upload.addEventListener('progress', function (ev) { | |||
if (ev.lengthComputable) { | |||
setProgress(ev.loaded, ev.total); | |||
} | |||
}); | |||
} | |||
return xhr; | |||
} | |||
}); | |||
} | |||
doUpload().then(function (data) { | |||
uploadedName = data.upload && data.upload.filename; | |||
if (!uploadedName) { | |||
throw new Error((data.error && data.error.info) || 'Upload failed'); | |||
} | |||
setProgress(file.size, file.size); | |||
$progress.addClass('is-indeterminate'); | |||
$progressLabel.text('Finishing…'); | |||
setStatus('Adding to Library list…'); | |||
return appendToLibrary(api, uploadedName, displayTitle, note); | |||
}).done(function () { | |||
$progress.removeClass('is-indeterminate'); | |||
$progressLabel.text('100% · done'); | |||
setStatus('Done. Reloading…'); | |||
window.location.reload(); | |||
}).fail(function (code, data) { | |||
var err = failMessage(code, data); | |||
if (uploadedName) { | |||
err = 'File uploaded as “' + uploadedName + '”, but the Library list was not updated: ' + err + | |||
' Refresh this page; if it is missing from the list, say so and we can add it.'; | |||
} | |||
setStatus(String(err), true); | |||
resetProgress(); | |||
$submit.prop('disabled', false); | |||
}); | |||
}); | |||
}); | |||
(function rbBooksFilters() { | |||
var $host = $('.books-filters-host').first(); | |||
var $slides = $('.mw-parser-output .notable-slides').first(); | |||
if (!$host.length || !$slides.length) return; | |||
if (!$slides.find('.notable-slide-card[class*="book-kind-"]').length) return; | |||
if ($host.find('.books-filters').length) return; | |||
var filters = [ | |||
{ id: 'all', label: 'All' }, | |||
{ id: 'memoir', label: 'Memoirs & letters' }, | |||
{ id: 'history', label: 'History' }, | |||
{ id: 'novel', label: 'Novels' }, | |||
{ id: 'other', label: 'Other' } | |||
]; | |||
var $bar = $('<div class="books-filters" role="group" aria-label="Filter books"></div>'); | |||
filters.forEach(function (f, i) { | |||
var $btn = $('<button type="button" class="books-filter"></button>'); | |||
$btn.attr('data-filter', f.id).text(f.label); | |||
if (i === 0) $btn.addClass('is-active'); | |||
$bar.append($btn); | |||
}); | |||
$host.empty().append($bar); | |||
$bar.on('click', '.books-filter', function () { | |||
var kind = $(this).attr('data-filter'); | |||
$bar.find('.books-filter').removeClass('is-active'); | |||
$(this).addClass('is-active'); | |||
$slides.find('.notable-slide-card').each(function () { | |||
var $c = $(this); | |||
var cls = $c.attr('class') || ''; | |||
var m = cls.match(/book-kind-([a-z]+)/); | |||
var k = m ? m[1] : 'other'; | |||
var show = kind === 'all' || k === kind; | |||
$c.toggleClass('is-book-hidden', !show); | |||
}); | |||
}); | |||
}()); | |||
/* Related-media wrap removed: story/interview cues already sit under the lead | |||
* with their own labels (Full story, etc.). Wrapping them added a stray | |||
* Related media heading near the top of the article. */ | |||