OCR multi-person: Select all matched + one match picker per person
OCR review UX: person cards, apply preview, Advanced
Line 1,354: Line 1,354:
];
];


function renderFactRow(fact, job, opts) {
function changeLineForFact(fact) {
opts = opts || {};
var field = fact.field || '';
var $row = $('<div>', {
var val = (fact.value || '').trim();
'class': 'ocr-fact-row' + (fact.already_on_page ? ' ocr-fact-already' : ''),
var label = (fact.label || '').toLowerCase();
'data-id': fact.id,
if (fact.already_on_page) {
'data-person': fact.person_key || ''
return (fact.label || 'Already on page') + ' — skipped';
});
}
var $check = $('<input>', { type: 'checkbox', checked: !!fact.selected });
if (field === 'ipw_ge') {
var $label = $('<span>', { 'class': 'ocr-fact-label' }).text((fact.label || fact.kind) + ' ');
return 'Team: set IPW ' + val;
var $val = $('<input>', { type: 'text', value: fact.value || '' });
}
var $tgt = $('<input>', {
if (field === 'teams') {
type: 'text',
return 'Team: add ' + val + ' to teams';
value: fact.target_title || job.bound_title || '',
}
placeholder: 'Soldier page title',
if (field === 'asn') {
'class': 'ocr-fact-target'
return 'ASN: ' + val;
});
}
if (job.bound_title && !isInbox) {
if (field === 'rank_final') {
$tgt.prop('readonly', true);
return 'Rank: ' + val;
}
if (field === 'specialty') {
return 'Role: ' + val;
}
if (field === 'class_number') {
return 'Class: ' + val;
}
}
if (opts.hideTarget) {
if (fact.kind === 'bio') {
$tgt.hide();
return 'Biography: ' + (val.length > 80 ? val.slice(0, 80) + '…' : val);
}
}
var metaBits = [(fact.kind || ''), 'conf ' + (fact.confidence || 0) + '%'];
if (fact.kind === 'note') {
if (fact.already_on_page) {
return (fact.label || 'Note') + ': ' + (val.length > 90 ? val.slice(0, 90) + '' : val);
metaBits.push('already on page');
}
}
var $meta = $('<span>', { 'class': 'ocr-fact-meta' }).text(metaBits.join(' · '));
if (label.indexOf('team') !== -1 || label.indexOf('ipw') !== -1) {
$row.append($check, $label, $val, $tgt, $meta);
return fact.label + (val ? ': ' + val : '');
if (!opts.hideMatchPick && fact.match_candidates && fact.match_candidates.length && !fact.target_title) {
var $pick = $('<select>', { 'class': 'ocr-match-pick' });
$pick.append($('<option>', { value: '', text: 'Pick match…' }));
fact.match_candidates.forEach(function (t) {
$pick.append($('<option>', { value: t, text: t }));
});
$pick.on('change', function () {
var v = $pick.val();
if (v) {
$tgt.val(v);
/* Propagate title to all rows for this person */
var pk = fact.person_key;
if (pk) {
$listRef.find('.ocr-fact-row[data-person="' + pk.replace(/"/g, '') + '"] .ocr-fact-target').val(v);
}
}
});
$row.append($pick);
}
}
return $row;
return (fact.label || field || fact.kind) + (val ? ': ' + val : '');
}
 
function personDisplayName(fact) {
var lab = fact.person_label || fact.target_title || 'Person';
return lab.split('·')[0].trim().split('(')[0].trim() || lab;
}
}


var $listRef = null;
function personMetaBits(items) {
var bits = [];
items.forEach(function (f) {
if (f.field === 'asn' && f.value && bits.indexOf('ASN ' + f.value) < 0) {
bits.push('ASN ' + f.value);
}
if (f.field === 'rank_final' && f.value && bits.indexOf(f.value) < 0) {
bits.push(f.value);
}
if (f.field === 'specialty' && f.value && bits.indexOf(f.value) < 0) {
bits.push(f.value);
}
if ((f.field === 'ipw_ge' || f.field === 'teams') && f.value) {
var t = f.value.indexOf('IPW') === 0 ? f.value : ('IPW ' + f.value);
if (bits.indexOf(t) < 0) {
bits.push(t);
}
}
});
return bits;
}


function renderPersonGroups($list, proposed, job) {
function groupFactsByPerson(proposed) {
var order = [];
var order = [];
var byPerson = {};
var byPerson = {};
proposed.forEach(function (fact) {
proposed.forEach(function (fact) {
var key = fact.person_key || '_ungrouped';
var key = fact.person_key || (fact.target_title ? 'title:' + fact.target_title : '_single');
if (!byPerson[key]) {
if (!byPerson[key]) {
byPerson[key] = [];
byPerson[key] = [];
Line 1,417: Line 1,429:
byPerson[key].push(fact);
byPerson[key].push(fact);
});
});
order.forEach(function (key) {
return { order: order, byPerson: byPerson };
var items = byPerson[key];
}
 
function isWritableFact(fact) {
return fact.kind === 'field' || fact.kind === 'bio';
}
 
function bindSoldierTypeahead($input, $suggest, onPick) {
var timer = null;
$input.on('input', function () {
var q = $.trim($input.val() || '');
window.clearTimeout(timer);
$suggest.empty().hide();
if (q.length < 2) {
return;
}
timer = window.setTimeout(function () {
api().get({
action: 'opensearch',
search: q,
namespace: 0,
limit: 8,
redirects: 'resolve'
}).then(function (data) {
var titles = (data && data[1]) || [];
$suggest.empty();
if (!titles.length) {
$suggest.hide();
return;
}
titles.forEach(function (t) {
var $opt = $('<button>', { type: 'button', 'class': 'ocr-suggest-item', text: t });
$opt.on('click', function () {
onPick(t);
$suggest.empty().hide();
});
$suggest.append($opt);
});
$suggest.show();
});
}, 220);
});
}
 
function renderChangeRow(fact, job, opts) {
opts = opts || {};
var writable = isWritableFact(fact) && !fact.already_on_page;
var $row = $('<div>', {
'class': 'ocr-fact-row' +
(fact.already_on_page ? ' ocr-fact-already' : '') +
(writable ? ' ocr-fact-writable' : ' ocr-fact-note') +
(fact.kind === 'note' ? ' ocr-fact-is-note' : ''),
'data-id': fact.id,
'data-person': fact.person_key || '',
'data-field': fact.field || '',
'data-kind': fact.kind || ''
});
var $check = $('<input>', {
type: 'checkbox',
checked: !!fact.selected && writable,
disabled: !writable
});
var $line = $('<span>', { 'class': 'ocr-change-line' }).text(changeLineForFact(fact));
var $val = $('<input>', {
type: 'text',
'class': 'ocr-fact-value',
value: fact.value || ''
});
var $tgt = $('<input>', {
type: 'hidden',
'class': 'ocr-fact-target',
value: fact.target_title || job.bound_title || ''
});
var $details = $('<details>', { 'class': 'ocr-fact-details' });
$details.append($('<summary>').text('Edit'));
$details.append($('<label>').text('Value ').append($val));
if (!opts.hideTargetVisible) {
var $tgtVis = $('<input>', {
type: 'text',
'class': 'ocr-fact-target-vis',
value: fact.target_title || job.bound_title || '',
placeholder: 'Soldier page title'
});
if (job.bound_title && !isInbox) {
$tgtVis.prop('readonly', true);
}
$tgtVis.on('input change', function () {
$tgt.val($.trim($tgtVis.val() || ''));
});
$details.append($('<label>').text('Page ').append($tgtVis));
}
$details.append($('<span>', { 'class': 'ocr-fact-meta' }).text(
(fact.kind || '') + ' · conf ' + (fact.confidence || 0) + '%'
));
if (writable || fact.already_on_page) {
$row.append($check, $line, $tgt, $details);
} else {
$row.append($line, $tgt, $details);
$check.prop('checked', false);
}
/* Keep a hidden checkbox state for notes (never apply) */
if (!writable) {
$row.append($('<input>', { type: 'checkbox', checked: false, 'class': 'ocr-fact-check-hidden' }).hide());
}
return $row;
}
 
var $listRef = null;
 
function renderPersonCards($list, proposed, job, onChange) {
var grouped = groupFactsByPerson(proposed);
grouped.order.forEach(function (key) {
var items = grouped.byPerson[key];
var first = items[0] || {};
var first = items[0] || {};
var $g = $('<div>', { 'class': 'ocr-person-group' + (first.target_title ? ' ocr-person-matched' : ' ocr-person-unresolved') });
var title = first.target_title || '';
var head = first.person_label || first.target_title || 'Unresolved person';
var writable = items.filter(function (f) {
var $h = $('<h4>').text(head);
return isWritableFact(f) && !f.already_on_page;
if (first.target_title) {
});
$h.append($('<span>', { 'class': 'ocr-fact-meta' }).text(' → ' + first.target_title));
var notes = items.filter(function (f) {
} else {
return f.kind === 'note' || f.already_on_page;
$h.append($('<span>', { 'class': 'ocr-fact-meta' }).text(' — needs soldier match'));
});
var included = writable.some(function (f) { return f.selected; });
var $card = $('<div>', {
'class': 'ocr-person-card' + (title ? ' ocr-person-matched' : ' ocr-person-unresolved'),
'data-person': key
});
var $head = $('<div>', { 'class': 'ocr-card-head' });
var $include = $('<input>', {
type: 'checkbox',
'class': 'ocr-include',
checked: included && !!title
});
$head.append($('<label>', { 'class': 'ocr-include-label' }).append($include, ' Include'));
$head.append($('<div>', { 'class': 'ocr-card-name' }).text(personDisplayName(first)));
var meta = personMetaBits(items);
if (meta.length) {
$head.append($('<div>', { 'class': 'ocr-card-meta' }).text(meta.join(' · ')));
}
}
$g.append($h);
$card.append($head);
var sharedTitle = first.target_title || '';
 
var $match = $('<div>', { 'class': 'ocr-card-match' });
var $tgtShared = $('<input>', {
var $tgtShared = $('<input>', {
type: 'text',
type: 'text',
'class': 'ocr-person-title',
'class': 'ocr-person-title',
value: sharedTitle,
value: title,
placeholder: 'Soldier page title for this person'
placeholder: 'Find soldier (name or ASN)…'
});
var $suggest = $('<div>', { 'class': 'ocr-suggest' }).hide();
var $matchStatus = $('<div>', { 'class': 'ocr-match-status' });
function setMatch(t) {
title = t || '';
$tgtShared.val(title);
$card.find('.ocr-fact-target').val(title);
if (title) {
$card.removeClass('ocr-person-unresolved').addClass('ocr-person-matched');
$matchStatus.empty().append(
$('<span>').text('Matched: '),
$('<a>', { href: mw.util.getUrl(title), text: title, target: '_blank' })
);
} else {
$card.removeClass('ocr-person-matched').addClass('ocr-person-unresolved');
$matchStatus.text('Needs a soldier match before Apply');
$include.prop('checked', false);
}
if (onChange) {
onChange();
}
}
if (title) {
setMatch(title);
} else {
$matchStatus.text('Needs a soldier match before Apply');
}
$match.append($matchStatus);
$match.append($('<label>').text('Wiki page ').append($tgtShared));
$match.append($suggest);
bindSoldierTypeahead($tgtShared, $suggest, setMatch);
$tgtShared.on('change', function () {
setMatch($.trim($tgtShared.val() || ''));
});
});
$g.append($('<label>').text('Wiki page ').append($tgtShared));
/* One match picker per person (not per fact row) */
var candidates = first.match_candidates || [];
var candidates = first.match_candidates || [];
items.forEach(function (f) {
items.forEach(function (f) {
Line 1,444: Line 1,615:
}
}
});
});
if (candidates && candidates.length && !sharedTitle) {
if (candidates && candidates.length) {
var $pick = $('<select>', { 'class': 'ocr-match-pick' });
var $pick = $('<select>', { 'class': 'ocr-match-pick' });
$pick.append($('<option>', { value: '', text: 'Pick match…' }));
$pick.append($('<option>', { value: '', text: 'Suggested matches…' }));
candidates.forEach(function (t) {
candidates.forEach(function (t) {
$pick.append($('<option>', { value: t, text: t }));
$pick.append($('<option>', { value: t, text: t, selected: t === title }));
});
});
$pick.on('change', function () {
$pick.on('change', function () {
var v = $pick.val();
if ($pick.val()) {
if (v) {
setMatch($pick.val());
$tgtShared.val(v).trigger('change');
$g.removeClass('ocr-person-unresolved').addClass('ocr-person-matched');
}
}
});
});
$g.append($pick);
$match.append($pick);
}
$card.append($match);
 
var $changes = $('<div>', { 'class': 'ocr-card-changes' });
writable.forEach(function (fact) {
var $row = renderChangeRow(fact, job, { hideTargetVisible: true });
$row.find('.ocr-fact-target').val(title);
$changes.append($row);
});
if (!writable.length && notes.some(function (n) { return n.already_on_page; })) {
notes.filter(function (n) { return n.already_on_page; }).forEach(function (fact) {
$changes.append(renderChangeRow(fact, job, { hideTargetVisible: true }));
});
}
$card.append($changes);
 
var detailNotes = notes.filter(function (n) { return n.kind === 'note'; });
if (detailNotes.length) {
var $more = $('<details>', { 'class': 'ocr-card-more' });
$more.append($('<summary>').text('Document details'));
detailNotes.forEach(function (fact) {
var $row = renderChangeRow(fact, job, { hideTargetVisible: true });
$row.find('.ocr-fact-target').val(title);
$more.append($row);
});
$card.append($more);
}
}
$tgtShared.on('input change', function () {
 
var v = $.trim($tgtShared.val() || '');
$include.on('change', function () {
$g.find('.ocr-fact-target').val(v);
var on = $include.prop('checked');
if (v) {
if (on && !$.trim($tgtShared.val() || '')) {
$g.removeClass('ocr-person-unresolved').addClass('ocr-person-matched');
$include.prop('checked', false);
} else {
$matchStatus.text('Match a soldier page first, then Include.');
$g.removeClass('ocr-person-matched').addClass('ocr-person-unresolved');
return;
}
$card.find('.ocr-fact-writable input[type="checkbox"]').prop('checked', on);
if (onChange) {
onChange();
}
}
});
});
items.forEach(function (fact) {
$card.on('change', 'input[type="checkbox"], input.ocr-fact-value', function () {
var $row = renderFactRow(fact, job, { hideTarget: true, hideMatchPick: true });
if (onChange) {
$row.find('.ocr-fact-target').val(sharedTitle || fact.target_title || '');
onChange();
$g.append($row);
}
});
});
$list.append($g);
$list.append($card);
});
});
}
function renderSingleChangeCard($list, proposed, job, onChange) {
var $card = $('<div>', { 'class': 'ocr-person-card ocr-person-matched ocr-single-card' });
var title = job.bound_title || '';
$card.append($('<div>', { 'class': 'ocr-card-name' }).text(title || 'This soldier'));
if (title) {
$card.append($('<div>', { 'class': 'ocr-match-status' }).append(
$('<a>', { href: mw.util.getUrl(title), text: title, target: '_blank' })
));
}
var $tgtHidden = $('<input>', { type: 'hidden', 'class': 'ocr-person-title', value: title });
$card.append($tgtHidden);
var primary = proposed.filter(function (f) {
return isWritableFact(f) && !f.already_on_page;
});
var skipped = proposed.filter(function (f) { return f.already_on_page; });
var notes = proposed.filter(function (f) { return f.kind === 'note'; });
var $changes = $('<div>', { 'class': 'ocr-card-changes' });
if (!primary.length) {
$changes.append($('<p>', { 'class': 'ocr-fact-meta' }).text('No new fields to write (or all already on the page).'));
}
primary.forEach(function (fact) {
var $row = renderChangeRow(fact, job, { hideTargetVisible: true });
$row.find('.ocr-fact-target').val(title);
$changes.append($row);
});
skipped.forEach(function (fact) {
$changes.append(renderChangeRow(fact, job, { hideTargetVisible: true }));
});
$card.append($changes);
if (notes.length) {
var $more = $('<details>', { 'class': 'ocr-card-more' });
$more.append($('<summary>').text('OCR notes (not written to the page)'));
notes.forEach(function (fact) {
var $row = renderChangeRow(fact, job, { hideTargetVisible: true });
$row.find('.ocr-fact-target').val(title);
$more.append($row);
});
$card.append($more);
}
$card.on('change', 'input', function () {
if (onChange) {
onChange();
}
});
$list.append($card);
}
function renderApplySuccess($host, job) {
$host.empty();
var $panel = $('<div>', { 'class': 'ocr-review-panel ocr-apply-success' });
$panel.append($('<p>').append($('<strong>').text('Applied')));
var log = job.apply_log || [];
var titles = {};
log.forEach(function (line) {
var m = /^([^:]+):\s*(.+)$/.exec(line);
if (m && m[1].indexOf('team stub') < 0) {
titles[m[1]] = titles[m[1]] || [];
titles[m[1]].push(m[2]);
}
});
var keys = Object.keys(titles);
if (keys.length) {
var $ul = $('<ul>', { 'class': 'ocr-apply-links' });
keys.forEach(function (t) {
var $li = $('<li>');
$li.append($('<a>', { href: mw.util.getUrl(t), text: t }));
$li.append($('<span>', { 'class': 'ocr-fact-meta' }).text(' — ' + titles[t].join('; ')));
$ul.append($li);
});
$panel.append($ul);
} else {
$panel.append($('<p>').text(log.join(' · ') || 'Done.'));
}
if (isSoldier) {
$panel.append($('<p>').append(
$('<button>', { type: 'button', text: 'Reload page' }).on('click', function () {
window.location.reload();
}),
document.createTextNode(' to see updates on this profile.')
));
}
$host.append($panel);
}
}


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


Line 1,500: Line 1,779:
(ps.already_on_page || 0) + ' already have team' +
(ps.already_on_page || 0) + ' already have team' +
(ps.teams && ps.teams.length ? (' · teams ' + ps.teams.join(', ')) : '') +
(ps.teams && ps.teams.length ? (' · teams ' + ps.teams.join(', ')) : '') +
(ps.inferred_teams && ps.inferred_teams.length ? (' · inferred ' + ps.inferred_teams.join(', ')) : '') +
(ps.inferred_teams && ps.inferred_teams.length ? (' · inferred ' + ps.inferred_teams.join(', ')) : '')
(ps.groups && ps.groups.length && !(ps.teams && ps.teams.length) ? (' · ' + ps.groups.length + ' groups') : '')
));
} else {
$panel.append($('<p>', { 'class': 'ocr-fact-meta' }).text(
(job.doc_type_label || 'Document') +
' · OCR quality ' + (job.ocr_quality || 0) + '%' +
(job.error ? (' · Error: ' + job.error) : '')
));
));
}
}
Line 1,515: Line 1,799:


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 ocr-preview-sticky' });
var pages = job.pages || [];
var pages = job.pages || [];
if (pages.length > 1) {
if (pages.length > 1) {
$prev.append($('<p>').append($('<strong>').text('Pages (uncheck to exclude, then Rebuild)')));
$prev.append($('<p>').append($('<strong>').text('Pages')));
var $pageList = $('<div>', { 'class': 'ocr-page-picker' });
var $pageList = $('<div>', { 'class': 'ocr-page-picker' });
pages.forEach(function (pg) {
pages.forEach(function (pg) {
Line 1,527: Line 1,811:
$pg.append($('<img>', { src: imgUrl, alt: 'Page ' + (pg.index + 1) }));
$pg.append($('<img>', { src: imgUrl, alt: 'Page ' + (pg.index + 1) }));
}
}
$pg.append($cb, document.createTextNode(' Page ' + (pg.index + 1) + ' (' + (pg.char_count || 0) + ' chars)'));
$pg.append($cb, document.createTextNode(' Page ' + (pg.index + 1)));
$pageList.append($pg);
$pageList.append($pg);
});
});
Line 1,541: Line 1,825:


var $facts = $('<div>', { 'class': 'ocr-review-facts' });
var $facts = $('<div>', { 'class': 'ocr-review-facts' });
var proposed = job.proposed || [];
var $list = $('<div>', { 'class': 'ocr-fact-groups ocr-card-list' });
$listRef = $list;
var $previewBox = $('<div>', { 'class': 'ocr-apply-preview' });
$previewBox.append($('<strong>').text('What will change'));
var $previewUl = $('<ul>', { 'class': 'ocr-apply-preview-list' });
$previewBox.append($previewUl);
function refreshPreview() {
var props = collectProposed();
var lines = [];
props.forEach(function (p) {
if (!p.selected || !isWritableFact(p) || p.already_on_page) {
return;
}
if (!(p.target_title || '').trim()) {
return;
}
var who = personDisplayName(p);
lines.push(who + ' → ' + changeLineForFact(p));
});
$previewUl.empty();
if (!lines.length) {
$previewUl.append($('<li>', { 'class': 'ocr-fact-meta' }).text('Nothing selected yet.'));
} else {
lines.forEach(function (ln) {
$previewUl.append($('<li>').text(ln));
});
}
}
if (job.multi_person) {
var $toolbar = $('<div>', { 'class': 'ocr-multi-toolbar' });
var $selMatched = $('<button>', { type: 'button', text: 'Include all matched' });
var $deselUnres = $('<button>', { type: 'button', text: 'Exclude unmatched' });
$toolbar.append($selMatched, $deselUnres);
$facts.append($toolbar);
renderPersonCards($list, proposed, job, refreshPreview);
$selMatched.on('click', function () {
$list.find('.ocr-person-matched').each(function () {
var $card = $(this);
$card.find('.ocr-include').prop('checked', true).trigger('change');
$card.find('.ocr-fact-writable input[type="checkbox"]').prop('checked', true);
});
refreshPreview();
});
$deselUnres.on('click', function () {
$list.find('.ocr-person-unresolved .ocr-include').prop('checked', false);
$list.find('.ocr-person-unresolved input[type="checkbox"]').prop('checked', false);
refreshPreview();
});
} else {
renderSingleChangeCard($list, proposed, job, refreshPreview);
}
$facts.append($list);
$facts.append($previewBox);
/* Advanced: OCR text, doc type, gallery, overwrite, rebuild */
var $adv = $('<details>', { 'class': 'ocr-advanced' });
$adv.append($('<summary>').text('Advanced'));
var $advBody = $('<div>', { 'class': 'ocr-advanced-body' });


var $docType = $('<label>', { 'class': 'ocr-doc-type' }).text('Document type ');
var $docType = $('<label>', { 'class': 'ocr-doc-type' }).text('Document type ');
Line 1,552: Line 1,898:
});
});
$docType.append($docSelect);
$docType.append($docSelect);
$facts.append($docType);
$advBody.append($docType);


$facts.append($('<label>').text('Gallery caption'));
$advBody.append($('<label>').text('Gallery caption'));
var $caption = $('<input>', {
var $caption = $('<input>', {
type: 'text',
type: 'text',
Line 1,560: Line 1,906:
value: job.gallery_caption || ''
value: job.gallery_caption || ''
});
});
$facts.append($caption);
$advBody.append($caption);


$facts.append($('<label>').text('OCR text (editable)'));
$advBody.append($('<label>').text('OCR text (editable)'));
var $text = $('<textarea>').val(job.ocr_text || '');
var $text = $('<textarea>').val(job.ocr_text || '');
$facts.append($text);
$advBody.append($text);
 
var proposed = job.proposed || [];
var $list = $('<div>', { 'class': 'ocr-fact-groups' });
$listRef = $list;
 
if (job.multi_person) {
$facts.append($('<p>').append($('<strong>').text('People on this document')));
var $toolbar = $('<div>', { 'class': 'ocr-multi-toolbar' });
var $selMatched = $('<button>', { type: 'button', text: 'Select all matched' });
var $deselUnres = $('<button>', { type: 'button', text: 'Deselect unresolved' });
$toolbar.append($selMatched, $deselUnres);
$facts.append($toolbar);
renderPersonGroups($list, proposed, job);
$selMatched.on('click', function () {
$list.find('.ocr-person-matched .ocr-fact-row').each(function () {
var $row = $(this);
var label = ($row.find('.ocr-fact-label').text() || '').toLowerCase();
/* Prefer team/IPW rows; skip notes and already-on-page */
if (label.indexOf('team') !== -1 || label.indexOf('ipw') !== -1) {
$row.find('input[type="checkbox"]').prop('checked', !$row.hasClass('ocr-fact-already'));
}
});
});
$deselUnres.on('click', function () {
$list.find('.ocr-person-unresolved input[type="checkbox"]').prop('checked', false);
});
} else {
var groups = [
{ id: 'infobox', title: 'Infobox fields' },
{ id: 'bio', title: 'Biography' },
{ id: 'notes', title: 'Notes (review only)' }
];
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);
});
var shown = {};
$list.find('.ocr-fact-row').each(function () {
shown[$(this).attr('data-id')] = true;
});
proposed.forEach(function (fact) {
if (!shown[fact.id]) {
$list.append(renderFactRow(fact, job));
}
});
$facts.append($('<p>').append($('<strong>').text('Proposed facts')));
}
$facts.append($list);


var $overwrite = $('<label>').append(
var $overwrite = $('<label>').append(
Line 1,640: Line 1,927:
$forceId.hide();
$forceId.hide();
}
}
$facts.append($overwrite, $('<br>'), $gallery, $('<br>'), $forceId);
$advBody.append($overwrite, $('<br>'), $gallery, $('<br>'), $forceId);
 
$layout.append($prev, $facts);
$panel.append($layout);


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' });
var $rebuild = $('<button>', { type: 'button', text: 'Rebuild from pages' });
Line 1,651: Line 1,934:
$rebuild.hide();
$rebuild.hide();
}
}
$advBody.append($('<div>', { 'class': 'ocr-advanced-actions' }).append($save, $rebuild));
$adv.append($advBody);
$facts.append($adv);
$layout.append($prev, $facts);
$panel.append($layout);
var $actions = $('<div>', { 'class': 'ocr-review-actions' });
var $discard = $('<button>', { type: 'button', text: 'Discard' });
var $discard = $('<button>', { type: 'button', text: 'Discard' });
var $apply = $('<button>', { type: 'button', text: job.multi_person ? 'Apply selected to all matched' : 'Apply selected' });
var $apply = $('<button>', {
type: 'button',
'class': 'ocr-apply-btn',
text: job.multi_person ? 'Apply selected' : 'Apply selected'
});
var $status = $('<p>', { 'class': 'ocr-review-status' });
var $status = $('<p>', { 'class': 'ocr-review-status' });
if (!cfg.canApply) {
if (!cfg.canApply) {
Line 1,658: Line 1,953:
$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, $rebuild, $discard, $apply, $status);
$actions.append($discard, $apply, $status);
$panel.append($actions);
$panel.append($actions);
$host.append($panel);
$host.append($panel);
refreshPreview();


function collectProposed() {
function collectProposed() {
Line 1,676: Line 1,972:
return;
return;
}
}
base.selected = $row.find('input[type="checkbox"]').prop('checked');
var $cb = $row.find('input[type="checkbox"]').first();
base.value = $.trim($row.find('input[type="text"]').not('.ocr-fact-target').not('.ocr-person-title').first().val() || '');
base.selected = $cb.length ? !!$cb.prop('checked') : false;
var $t = $row.find('.ocr-fact-target');
if ($row.hasClass('ocr-fact-is-note') || $row.hasClass('ocr-fact-note') && !base.already_on_page) {
if (!$t.length || !$t.is(':visible')) {
base.selected = false;
$t = $row.closest('.ocr-person-group').find('.ocr-person-title');
}
}
base.value = $.trim($row.find('.ocr-fact-value').val() || base.value || '');
var $card = $row.closest('.ocr-person-card');
var $t = $card.find('.ocr-person-title');
if ($t.length) {
if ($t.length) {
base.target_title = $.trim($t.val() || '');
base.target_title = $.trim($t.val() || '');
} else {
} else {
base.target_title = $.trim($row.find('input[type="text"]').eq(1).val() || '');
base.target_title = $.trim($row.find('.ocr-fact-target').val() || job.bound_title || '');
}
}
$row.find('.ocr-fact-target').val(base.target_title);
out.push(base);
out.push(base);
});
});
Line 1,723: Line 2,022:
job_id: job.id,
job_id: job.id,
payload: JSON.stringify(payload())
payload: JSON.stringify(payload())
}).then(function (resp) {
}).then(function () {
$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) {
}).catch(function (err) {
$status.text(String(err));
$status.text(String(err));
Line 1,742: Line 2,037:
payload: JSON.stringify(payload())
payload: JSON.stringify(payload())
}).then(function (resp) {
}).then(function (resp) {
var j = resp.job || resp;
renderReview($host, resp.job || resp, cfg);
renderReview($host, j, cfg);
}).catch(function (err) {
}).catch(function (err) {
$status.text(String(err && err.error && err.error.info || err));
$status.text(String(err && err.error && err.error.info || err));
Line 1,757: Line 2,051:
payload: JSON.stringify(payload())
payload: JSON.stringify(payload())
}).then(function (resp) {
}).then(function (resp) {
var j = resp.job || resp;
renderReview($host, resp.job || resp, cfg);
renderReview($host, j, cfg);
}).catch(function (err) {
}).catch(function (err) {
$status.text(String(err));
$status.text(String(err));
Line 1,775: Line 2,068:
$apply.on('click', function () {
$apply.on('click', function () {
if (!job.identity_ok && !$forceId.find('input').prop('checked')) {
if (!job.identity_ok && !$forceId.find('input').prop('checked')) {
$status.text('Confirm identity (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);
return;
}
var selected = collectProposed().filter(function (p) {
return p.selected && isWritableFact(p) && (p.target_title || '').trim();
});
if (!selected.length) {
$status.text('Select at least one change with a matched soldier.');
return;
return;
}
}
Line 1,785: Line 2,086:
payload: JSON.stringify(payload())
payload: JSON.stringify(payload())
}).then(function (resp) {
}).then(function (resp) {
var j = (resp.job || resp);
renderApplySuccess($host, resp.job || resp);
$status.text('Applied. ' + ((j.apply_log || []).join(' · ') || 'Done.'));
if (isSoldier) {
window.setTimeout(function () {
window.location.reload();
}, 1200);
}
}).catch(function (err) {
}).catch(function (err) {
$status.text(String(err && err.error && err.error.info || err));
$status.text(String(err && err.error && err.error.info || err));
Line 1,988: Line 2,283:
jobs.forEach(function (job) {
jobs.forEach(function (job) {
var $li = $('<li>');
var $li = $('<li>');
var label = (job.filename || job.id) +
var $row = $('<div>', { 'class': 'ocr-queue-item' });
(job.bound_title ? (' · ' + job.bound_title) : '') +
var thumb = publicPreviewUrl(job);
' · ' + (job.status || '?') +
if (thumb) {
$row.append($('<img>', {
'class': 'ocr-queue-thumb',
src: thumb,
alt: ''
}));
}
var $meta = $('<div>', { 'class': 'ocr-queue-meta' });
var ps = job.people_summary || {};
var peopleBit = job.multi_person
? ((ps.detected || 0) + ' people · ' + (ps.matched || 0) + ' matched · ' + (ps.unresolved || 0) + ' unmatched')
: (job.bound_title ? job.bound_title : 'single document');
$meta.append($('<div>').append(
$('<strong>').text(job.filename || job.id),
document.createTextNode(' · ' + (job.doc_type_label || job.doc_type || 'document'))
));
$meta.append($('<div>', { 'class': 'ocr-fact-meta' }).text(
peopleBit + ' · ' + (job.status || '?') +
(job.status === 'processing' ? (' ' + (job.progress || 0) + '%') : '') +
(job.status === 'processing' ? (' ' + (job.progress || 0) + '%') : '') +
' · conf ' + (job.confidence || 0) + '%' +
' · by ' + (job.uploader || '?')
' · by ' + (job.uploader || '?');
));
var $open = $('<button>', { type: 'button', text: 'Open' });
var $open = $('<button>', { type: 'button', text: 'Open' });
$li.append(document.createTextNode(label + ' '), $open);
$meta.append($open);
$row.append($meta);
$li.append($row);
$open.on('click', function () {
$open.on('click', function () {
$host2.empty();
$host2.empty();
Line 2,005: Line 2,319:
});
});
}
}
window.scrollTo({ top: $host2.offset().top - 40, behavior: 'smooth' });
});
});
$ul.append($li);
$ul.append($li);