MediaWiki:Common.js: Difference between revisions
Fix OCR/photo upload: use postWithToken multipart (MW 1.43 has no postWithFormData) |
OCR review UI: groups, progress, multipage, identity guard |
||
| Line 1,327: | Line 1,327: | ||
} | } | ||
function | function publicAssetUrl(path) { | ||
if (! | if (!path) { | ||
return ''; | return ''; | ||
} | } | ||
var base = (window.__ocrPublicUrl || '').replace(/\/$/, ''); | var base = (window.__ocrPublicUrl || '').replace(/\/$/, ''); | ||
if (base) { | if (base) { | ||
return base + | return base + path; | ||
} | } | ||
return ''; | return ''; | ||
} | |||
function publicPreviewUrl(job) { | |||
return publicAssetUrl(job && job.preview_url); | |||
} | |||
var DOC_TYPES = [ | |||
{ id: 'naturalization', label: 'Naturalization / citizenship' }, | |||
{ id: 'discharge', label: 'Discharge / separation' }, | |||
{ id: 'enlistment', label: 'Enlistment / induction' }, | |||
{ id: 'ritchie_cert', label: 'Camp Ritchie / MITC certificate' }, | |||
{ id: 'award', label: 'Award / citation' }, | |||
{ id: 'newspaper', label: 'Newspaper / clipping' }, | |||
{ id: 'other', label: 'Other document' } | |||
]; | |||
function renderFactRow(fact, job) { | |||
var $row = $('<div>', { | |||
'class': 'ocr-fact-row' + (fact.already_on_page ? ' ocr-fact-already' : ''), | |||
'data-id': fact.id | |||
}); | |||
var $check = $('<input>', { type: 'checkbox', checked: !!fact.selected }); | |||
var $label = $('<span>', { 'class': 'ocr-fact-label' }).text((fact.label || fact.kind) + ' '); | |||
var $val = $('<input>', { type: 'text', value: fact.value || '' }); | |||
var $tgt = $('<input>', { | |||
type: 'text', | |||
value: fact.target_title || job.bound_title || '', | |||
placeholder: 'Soldier page title' | |||
}); | |||
if (job.bound_title && !isInbox) { | |||
$tgt.prop('readonly', true); | |||
} | |||
var metaBits = [(fact.kind || ''), 'conf ' + (fact.confidence || 0) + '%']; | |||
if (fact.already_on_page) { | |||
metaBits.push('already on page'); | |||
} | |||
var $meta = $('<span>', { 'class': 'ocr-fact-meta' }).text(metaBits.join(' · ')); | |||
$row.append($check, $label, $val, $tgt, $meta); | |||
return $row; | |||
} | } | ||
| Line 1,350: | Line 1,388: | ||
' · OCR quality ' + (job.ocr_quality || 0) + '%' + | ' · OCR quality ' + (job.ocr_quality || 0) + '%' + | ||
' · Confidence ' + (job.confidence || 0) + '%' + | ' · Confidence ' + (job.confidence || 0) + '%' + | ||
(job.doc_type_label ? (' · ' + job.doc_type_label) : '') + | |||
(job.error ? (' · Error: ' + job.error) : '') | (job.error ? (' · Error: ' + job.error) : '') | ||
)); | )); | ||
if (job.identity_warning) { | |||
var $id = $('<div>', { | |||
'class': 'ocr-identity-banner' + (job.identity_ok ? ' ocr-identity-ok' : ' ocr-identity-warn') | |||
}); | |||
$id.append($('<strong>').text(job.identity_ok ? 'Identity check: ' : 'Identity warning: ')); | |||
$id.append(document.createTextNode(job.identity_warning)); | |||
$panel.append($id); | |||
} | |||
var $layout = $('<div>', { 'class': 'ocr-review-layout' }); | var $layout = $('<div>', { 'class': 'ocr-review-layout' }); | ||
var $prev = $('<div>', { 'class': 'ocr-review-preview' }); | var $prev = $('<div>', { 'class': 'ocr-review-preview' }); | ||
var | var pages = job.pages || []; | ||
if ( | if (pages.length > 1) { | ||
$prev.append($('<img>', { src: | $prev.append($('<p>').append($('<strong>').text('Pages (uncheck to exclude, then Rebuild)'))); | ||
var $pageList = $('<div>', { 'class': 'ocr-page-picker' }); | |||
pages.forEach(function (pg) { | |||
var $pg = $('<label>', { 'class': 'ocr-page-item', 'data-index': pg.index }); | |||
var $cb = $('<input>', { type: 'checkbox', checked: pg.selected !== false }); | |||
var imgUrl = publicAssetUrl(pg.preview_url); | |||
if (imgUrl) { | |||
$pg.append($('<img>', { src: imgUrl, alt: 'Page ' + (pg.index + 1) })); | |||
} | |||
$pg.append($cb, document.createTextNode(' Page ' + (pg.index + 1) + ' (' + (pg.char_count || 0) + ' chars)')); | |||
$pageList.append($pg); | |||
}); | |||
$prev.append($pageList); | |||
} else { | } else { | ||
$prev.append($('<p>').text('Preview available after OCR finishes.')); | var prevUrl = publicPreviewUrl(job); | ||
if (prevUrl) { | |||
$prev.append($('<img>', { src: prevUrl, alt: 'Document preview' })); | |||
} else { | |||
$prev.append($('<p>').text('Preview available after OCR finishes.')); | |||
} | |||
} | } | ||
var $facts = $('<div>', { 'class': 'ocr-review-facts' }); | var $facts = $('<div>', { 'class': 'ocr-review-facts' }); | ||
var $docType = $('<label>', { 'class': 'ocr-doc-type' }).text('Document type '); | |||
var $docSelect = $('<select>'); | |||
DOC_TYPES.forEach(function (dt) { | |||
$docSelect.append($('<option>', { | |||
value: dt.id, | |||
text: dt.label, | |||
selected: (job.doc_type || 'other') === dt.id | |||
})); | |||
}); | |||
$docType.append($docSelect); | |||
$facts.append($docType); | |||
$facts.append($('<label>').text('Gallery caption')); | |||
var $caption = $('<input>', { | |||
type: 'text', | |||
'class': 'ocr-gallery-caption', | |||
value: job.gallery_caption || '' | |||
}); | |||
$facts.append($caption); | |||
$facts.append($('<label>').text('OCR text (editable)')); | $facts.append($('<label>').text('OCR text (editable)')); | ||
var $text = $('<textarea>').val(job.ocr_text || ''); | var $text = $('<textarea>').val(job.ocr_text || ''); | ||
| Line 1,367: | Line 1,454: | ||
var proposed = job.proposed || []; | var proposed = job.proposed || []; | ||
var $list = $('<div>'); | var groups = [ | ||
{ id: 'infobox', title: 'Infobox fields' }, | |||
{ id: 'bio', title: 'Biography' }, | |||
{ id: 'notes', title: 'Notes (review only)' } | |||
]; | |||
var $list = $('<div>', { 'class': 'ocr-fact-groups' }); | |||
groups.forEach(function (g) { | |||
var items = proposed.filter(function (f) { | |||
var grp = f.group || (f.kind === 'bio' ? 'bio' : (f.kind === 'field' ? 'infobox' : 'notes')); | |||
return grp === g.id; | |||
}); | |||
if (!items.length) { | |||
return; | |||
} | |||
var $g = $('<div>', { 'class': 'ocr-fact-group' }); | |||
$g.append($('<h4>').text(g.title + ' (' + items.length + ')')); | |||
items.forEach(function (fact) { | |||
$g.append(renderFactRow(fact, job)); | |||
}); | |||
$list.append($g); | |||
}); | |||
// Any ungrouped leftovers | |||
var shown = {}; | |||
$list.find('.ocr-fact-row').each(function () { | |||
shown[$(this).attr('data-id')] = true; | |||
}); | |||
proposed.forEach(function (fact) { | proposed.forEach(function (fact) { | ||
if (!shown[fact.id]) { | |||
$list.append(renderFactRow(fact, job)); | |||
} | } | ||
}); | }); | ||
$facts.append($('<p>').append($('<strong>').text('Proposed facts')), $list); | $facts.append($('<p>').append($('<strong>').text('Proposed facts')), $list); | ||
| Line 1,391: | Line 1,489: | ||
var $overwrite = $('<label>').append( | var $overwrite = $('<label>').append( | ||
$('<input>', { type: 'checkbox', checked: !!job.overwrite }), | $('<input>', { type: 'checkbox', checked: !!job.overwrite }), | ||
' Overwrite existing infobox fields' | ' Overwrite existing infobox fields (default: fill empty only)' | ||
); | ); | ||
var $gallery = $('<label>').append( | var $gallery = $('<label>').append( | ||
| Line 1,397: | Line 1,495: | ||
' Add document to Gallery on Apply' | ' Add document to Gallery on Apply' | ||
); | ); | ||
$facts.append($overwrite, $('<br>'), $gallery); | var $forceId = $('<label>', { 'class': 'ocr-force-identity' }).append( | ||
$('<input>', { type: 'checkbox', checked: false }), | |||
' I confirmed this document matches this soldier (override identity guard)' | |||
); | |||
if (job.identity_ok) { | |||
$forceId.hide(); | |||
} | |||
$facts.append($overwrite, $('<br>'), $gallery, $('<br>'), $forceId); | |||
$layout.append($prev, $facts); | $layout.append($prev, $facts); | ||
| Line 1,404: | Line 1,509: | ||
var $actions = $('<div>', { 'class': 'ocr-review-actions' }); | var $actions = $('<div>', { 'class': 'ocr-review-actions' }); | ||
var $save = $('<button>', { type: 'button', text: 'Save draft' }); | var $save = $('<button>', { type: 'button', text: 'Save draft' }); | ||
var $rebuild = $('<button>', { type: 'button', text: 'Rebuild from pages' }); | |||
if (pages.length < 2) { | |||
$rebuild.hide(); | |||
} | |||
var $discard = $('<button>', { type: 'button', text: 'Discard' }); | var $discard = $('<button>', { type: 'button', text: 'Discard' }); | ||
var $apply = $('<button>', { type: 'button', text: 'Apply selected' }); | var $apply = $('<button>', { type: 'button', text: 'Apply selected' }); | ||
| Line 1,411: | Line 1,520: | ||
$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($save, $discard, $apply, $status); | $actions.append($save, $rebuild, $discard, $apply, $status); | ||
$panel.append($actions); | $panel.append($actions); | ||
$host.append($panel); | $host.append($panel); | ||
| Line 1,433: | Line 1,542: | ||
base.target_title = $.trim($row.find('input[type="text"]').eq(1).val() || ''); | base.target_title = $.trim($row.find('input[type="text"]').eq(1).val() || ''); | ||
out.push(base); | out.push(base); | ||
}); | |||
return out; | |||
} | |||
function collectPages() { | |||
var out = []; | |||
$prev.find('.ocr-page-item').each(function () { | |||
var $pg = $(this); | |||
out.push({ | |||
index: parseInt($pg.attr('data-index'), 10), | |||
selected: $pg.find('input[type="checkbox"]').prop('checked') | |||
}); | |||
}); | }); | ||
return out; | return out; | ||
| Line 1,442: | Line 1,563: | ||
proposed: collectProposed(), | proposed: collectProposed(), | ||
overwrite: $overwrite.find('input').prop('checked'), | overwrite: $overwrite.find('input').prop('checked'), | ||
add_to_gallery: $gallery.find('input').prop('checked') | add_to_gallery: $gallery.find('input').prop('checked'), | ||
pages: collectPages(), | |||
doc_type: $docSelect.val(), | |||
gallery_caption: $.trim($caption.val() || ''), | |||
force_identity: $forceId.find('input').prop('checked') | |||
}; | }; | ||
} | } | ||
| Line 1,452: | Line 1,577: | ||
job_id: job.id, | job_id: job.id, | ||
payload: JSON.stringify(payload()) | payload: JSON.stringify(payload()) | ||
}).then(function () { | }).then(function (resp) { | ||
$status.text('Draft saved.'); | $status.text('Draft saved.'); | ||
var j = resp.job || resp; | |||
if (j && j.gallery_caption) { | |||
$caption.val(j.gallery_caption); | |||
} | |||
}).catch(function (err) { | |||
$status.text(String(err)); | |||
}); | |||
}); | |||
$rebuild.on('click', function () { | |||
$status.text('Rebuilding facts from selected pages…'); | |||
$rebuild.prop('disabled', true); | |||
ocrPost({ | |||
op: 'rebuild', | |||
job_id: job.id, | |||
payload: JSON.stringify(payload()) | |||
}).then(function (resp) { | |||
var j = resp.job || resp; | |||
renderReview($host, j, cfg); | |||
}).catch(function (err) { | |||
$status.text(String(err && err.error && err.error.info || err)); | |||
$rebuild.prop('disabled', false); | |||
}); | |||
}); | |||
$docSelect.on('change', function () { | |||
$status.text('Updating document type…'); | |||
ocrPost({ | |||
op: 'draft', | |||
job_id: job.id, | |||
payload: JSON.stringify(payload()) | |||
}).then(function (resp) { | |||
var j = resp.job || resp; | |||
renderReview($host, j, cfg); | |||
}).catch(function (err) { | }).catch(function (err) { | ||
$status.text(String(err)); | $status.text(String(err)); | ||
| Line 1,469: | Line 1,628: | ||
$apply.on('click', function () { | $apply.on('click', function () { | ||
if (!job.identity_ok && !$forceId.find('input').prop('checked')) { | |||
$status.text('Confirm identity (checkbox) before Apply, or fix the wrong document.'); | |||
return; | |||
} | |||
$status.text('Applying…'); | $status.text('Applying…'); | ||
$apply.prop('disabled', true); | $apply.prop('disabled', true); | ||
| Line 1,491: | Line 1,654: | ||
function pollJob($host, jobId, cfg) { | function pollJob($host, jobId, cfg) { | ||
$host. | $host.empty(); | ||
var $status = $('<p>', { 'class': 'ocr-review-status' }).text('Running OCR…'); | |||
var $barWrap = $('<div>', { 'class': 'ocr-progress-wrap' }); | |||
var $bar = $('<div>', { 'class': 'ocr-progress-bar' }); | |||
$barWrap.append($bar); | |||
var $cancel = $('<button>', { type: 'button', text: 'Cancel' }); | |||
$host.append($status, $barWrap, $cancel); | |||
var tries = 0; | var tries = 0; | ||
var cancelled = false; | |||
$cancel.on('click', function () { | |||
cancelled = true; | |||
$cancel.prop('disabled', true); | |||
$status.text('Cancelling…'); | |||
ocrPost({ op: 'cancel', job_id: jobId }).then(function () { | |||
$host.empty().append($('<p>').text('OCR cancelled.')); | |||
}).catch(function (err) { | |||
$host.empty().append($('<p>').text(String(err))); | |||
}); | |||
}); | |||
function tick() { | function tick() { | ||
if (cancelled) { | |||
return; | |||
} | |||
tries += 1; | tries += 1; | ||
ocrGet({ op: 'job', job_id: jobId }).then(function (resp) { | ocrGet({ op: 'job', job_id: jobId }).then(function (resp) { | ||
var job = resp.job || resp; | var job = resp.job || resp; | ||
if (job.status === 'discarded') { | |||
$host.empty().append($('<p>').text('OCR cancelled.')); | |||
return; | |||
} | |||
if (job.status === 'queued' || job.status === 'processing') { | if (job.status === 'queued' || job.status === 'processing') { | ||
if (tries > | if (tries > 180) { | ||
$host.empty().append($('<p>').text('OCR timed out. | $host.empty().append($('<p>').text('OCR timed out. Open it later from the review queue.')); | ||
return; | return; | ||
} | } | ||
$ | var pct = typeof job.progress === 'number' ? job.progress : 0; | ||
window.setTimeout(tick, | $bar.css('width', pct + '%'); | ||
$status.text( | |||
(job.progress_message || 'Running OCR…') + | |||
' (' + pct + '%)' | |||
); | |||
window.setTimeout(tick, 1500); | |||
return; | return; | ||
} | } | ||
| Line 1,624: | Line 1,818: | ||
var $queue = $('<div>', { 'class': 'ocr-review-box' }); | var $queue = $('<div>', { 'class': 'ocr-review-box' }); | ||
$queue.append($('<h3>').text('Review queue')); | $queue.append($('<h3>').text('Review queue')); | ||
$queue.append($('<p>', { 'class': 'ocr-fact-meta' }).text( | |||
'Resume ready jobs, or reopen a profile upload that finished after you left the page.' | |||
)); | |||
var $ul = $('<ul>', { 'class': 'ocr-queue-list' }); | var $ul = $('<ul>', { 'class': 'ocr-queue-list' }); | ||
$queue.append($ul); | $queue.append($ul); | ||
| Line 1,629: | Line 1,826: | ||
function loadQueue() { | function loadQueue() { | ||
ocrGet({ op: 'queue', status: 'ready' }).then(function ( | ocrGet({ op: 'queue', status: 'ready' }).then(function (readyResp) { | ||
return ocrGet({ op: 'queue', status: 'processing' }).then(function (procResp) { | |||
return { | |||
ready: (readyResp && readyResp.jobs) || [], | |||
processing: (procResp && procResp.jobs) || [] | |||
}; | |||
}); | |||
}).then(function (bags) { | |||
$ul.empty(); | $ul.empty(); | ||
var jobs = | var jobs = [].concat(bags.processing || [], bags.ready || []); | ||
if (!jobs.length) { | if (!jobs.length) { | ||
$ul.append($('<li>').text('No ready jobs.')); | $ul.append($('<li>').text('No ready or in-progress jobs.')); | ||
return; | return; | ||
} | } | ||
| Line 1,640: | Line 1,844: | ||
var label = (job.filename || job.id) + | var label = (job.filename || job.id) + | ||
(job.bound_title ? (' · ' + job.bound_title) : '') + | (job.bound_title ? (' · ' + job.bound_title) : '') + | ||
' · ' + (job.status || '?') + | |||
(job.status === 'processing' ? (' ' + (job.progress || 0) + '%') : '') + | |||
' · conf ' + (job.confidence || 0) + '%' + | ' · conf ' + (job.confidence || 0) + '%' + | ||
' · by ' + (job.uploader || '?'); | ' · by ' + (job.uploader || '?'); | ||
| Line 1,646: | Line 1,852: | ||
$open.on('click', function () { | $open.on('click', function () { | ||
$host2.empty(); | $host2.empty(); | ||
renderReview($host2, job, cfg); | if (job.status === 'processing' || job.status === 'queued') { | ||
pollJob($host2, job.id, cfg); | |||
} else { | |||
ocrGet({ op: 'job', job_id: job.id }).then(function (resp) { | |||
renderReview($host2, resp.job || resp, cfg); | |||
}); | |||
} | |||
}); | }); | ||
$ul.append($li); | $ul.append($li); | ||