OCR review UI: groups, progress, multipage, identity guard
Multi-person OCR review UI for team rosters
Line 1,347: Line 1,347:
{ id: 'enlistment', label: 'Enlistment / induction' },
{ id: 'enlistment', label: 'Enlistment / induction' },
{ id: 'ritchie_cert', label: 'Camp Ritchie / MITC certificate' },
{ id: 'ritchie_cert', label: 'Camp Ritchie / MITC certificate' },
{ id: 'team_roster', label: 'IPW / team roster (special orders)' },
{ id: 'award', label: 'Award / citation' },
{ id: 'award', label: 'Award / citation' },
{ id: 'newspaper', label: 'Newspaper / clipping' },
{ id: 'newspaper', label: 'Newspaper / clipping' },
Line 1,352: Line 1,353:
];
];


function renderFactRow(fact, job) {
function renderFactRow(fact, job, opts) {
opts = opts || {};
var $row = $('<div>', {
var $row = $('<div>', {
'class': 'ocr-fact-row' + (fact.already_on_page ? ' ocr-fact-already' : ''),
'class': 'ocr-fact-row' + (fact.already_on_page ? ' ocr-fact-already' : ''),
'data-id': fact.id
'data-id': fact.id,
'data-person': fact.person_key || ''
});
});
var $check = $('<input>', { type: 'checkbox', checked: !!fact.selected });
var $check = $('<input>', { type: 'checkbox', checked: !!fact.selected });
Line 1,363: Line 1,366:
type: 'text',
type: 'text',
value: fact.target_title || job.bound_title || '',
value: fact.target_title || job.bound_title || '',
placeholder: 'Soldier page title'
placeholder: 'Soldier page title',
'class': 'ocr-fact-target'
});
});
if (job.bound_title && !isInbox) {
if (job.bound_title && !isInbox) {
$tgt.prop('readonly', true);
$tgt.prop('readonly', true);
}
if (opts.hideTarget) {
$tgt.hide();
}
}
var metaBits = [(fact.kind || ''), 'conf ' + (fact.confidence || 0) + '%'];
var metaBits = [(fact.kind || ''), 'conf ' + (fact.confidence || 0) + '%'];
Line 1,374: Line 1,381:
var $meta = $('<span>', { 'class': 'ocr-fact-meta' }).text(metaBits.join(' · '));
var $meta = $('<span>', { 'class': 'ocr-fact-meta' }).text(metaBits.join(' · '));
$row.append($check, $label, $val, $tgt, $meta);
$row.append($check, $label, $val, $tgt, $meta);
if (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 $row;
}
var $listRef = null;
function renderPersonGroups($list, proposed, job) {
var order = [];
var byPerson = {};
proposed.forEach(function (fact) {
var key = fact.person_key || '_ungrouped';
if (!byPerson[key]) {
byPerson[key] = [];
order.push(key);
}
byPerson[key].push(fact);
});
order.forEach(function (key) {
var items = byPerson[key];
var first = items[0] || {};
var $g = $('<div>', { 'class': 'ocr-person-group' + (first.target_title ? ' ocr-person-matched' : ' ocr-person-unresolved') });
var head = first.person_label || first.target_title || 'Unresolved person';
var $h = $('<h4>').text(head);
if (first.target_title) {
$h.append($('<span>', { 'class': 'ocr-fact-meta' }).text(' → ' + first.target_title));
} else {
$h.append($('<span>', { 'class': 'ocr-fact-meta' }).text(' — needs soldier match'));
}
$g.append($h);
var sharedTitle = first.target_title || '';
var $tgtShared = $('<input>', {
type: 'text',
'class': 'ocr-person-title',
value: sharedTitle,
placeholder: 'Soldier page title for this person'
});
$g.append($('<label>').text('Wiki page ').append($tgtShared));
$tgtShared.on('input change', function () {
var v = $.trim($tgtShared.val() || '');
$g.find('.ocr-fact-target').val(v);
});
items.forEach(function (fact) {
var $row = renderFactRow(fact, job, { hideTarget: true });
$row.find('.ocr-fact-target').val(sharedTitle || fact.target_title || '');
$g.append($row);
});
$list.append($g);
});
}
}


Line 1,391: Line 1,463:
(job.error ? (' · Error: ' + job.error) : '')
(job.error ? (' · Error: ' + job.error) : '')
));
));
if (job.multi_person && job.people_summary) {
var ps = job.people_summary;
$panel.append($('<p>', { 'class': 'ocr-people-summary' }).text(
(ps.detected || 0) + ' people · ' +
(ps.matched || 0) + ' matched · ' +
(ps.unresolved || 0) + ' need review · ' +
(ps.already_on_page || 0) + ' already have team' +
(ps.teams && ps.teams.length ? (' · teams ' + ps.teams.join(', ')) : '')
));
}


if (job.identity_warning) {
if (job.identity_warning) {
Line 1,454: Line 1,537:


var proposed = job.proposed || [];
var proposed = job.proposed || [];
var groups = [
{ id: 'infobox', title: 'Infobox fields' },
{ id: 'bio', title: 'Biography' },
{ id: 'notes', title: 'Notes (review only)' }
];
var $list = $('<div>', { 'class': 'ocr-fact-groups' });
var $list = $('<div>', { 'class': 'ocr-fact-groups' });
groups.forEach(function (g) {
$listRef = $list;
var items = proposed.filter(function (f) {
 
var grp = f.group || (f.kind === 'bio' ? 'bio' : (f.kind === 'field' ? 'infobox' : 'notes'));
if (job.multi_person) {
return grp === g.id;
$facts.append($('<p>').append($('<strong>').text('People on this document')));
var $toolbar = $('<div>', { 'class': 'ocr-multi-toolbar' });
var $selMatched = $('<button>', { type: 'button', text: 'Select matched team rows' });
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();
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;
});
});
if (!items.length) {
proposed.forEach(function (fact) {
return;
if (!shown[fact.id]) {
}
$list.append(renderFactRow(fact, job));
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);
$facts.append($('<p>').append($('<strong>').text('Proposed facts')));
});
}
// Any ungrouped leftovers
$facts.append($list);
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')), $list);


var $overwrite = $('<label>').append(
var $overwrite = $('<label>').append(
Line 1,492: Line 1,599:
);
);
var $gallery = $('<label>').append(
var $gallery = $('<label>').append(
$('<input>', { type: 'checkbox', checked: job.add_to_gallery !== false }),
$('<input>', { type: 'checkbox', checked: job.add_to_gallery === true }),
' Add document to Gallery on Apply'
' Add document to Gallery on Apply' + (job.multi_person ? ' (off by default for multi-person)' : '')
);
);
var $forceId = $('<label>', { 'class': 'ocr-force-identity' }).append(
var $forceId = $('<label>', { 'class': 'ocr-force-identity' }).append(
Line 1,499: Line 1,606:
' I confirmed this document matches this soldier (override identity guard)'
' I confirmed this document matches this soldier (override identity guard)'
);
);
if (job.identity_ok) {
if (job.identity_ok || job.multi_person) {
$forceId.hide();
$forceId.hide();
}
}
Line 1,514: Line 1,621:
}
}
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: job.multi_person ? 'Apply selected to all matched' : 'Apply selected' });
var $status = $('<p>', { 'class': 'ocr-review-status' });
var $status = $('<p>', { 'class': 'ocr-review-status' });
if (!cfg.canApply) {
if (!cfg.canApply) {
Line 1,539: Line 1,646:
}
}
base.selected = $row.find('input[type="checkbox"]').prop('checked');
base.selected = $row.find('input[type="checkbox"]').prop('checked');
base.value = $.trim($row.find('input[type="text"]').eq(0).val() || '');
base.value = $.trim($row.find('input[type="text"]').not('.ocr-fact-target').not('.ocr-person-title').first().val() || '');
base.target_title = $.trim($row.find('input[type="text"]').eq(1).val() || '');
var $t = $row.find('.ocr-fact-target');
if (!$t.length || !$t.is(':visible')) {
$t = $row.closest('.ocr-person-group').find('.ocr-person-title');
}
if ($t.length) {
base.target_title = $.trim($t.val() || '');
} else {
base.target_title = $.trim($row.find('input[type="text"]').eq(1).val() || '');
}
out.push(base);
out.push(base);
});
});
Line 1,805: Line 1,920:
$up.append($('<h3>').text('Upload a document'));
$up.append($('<h3>').text('Upload a document'));
$up.append($('<p>').text(
$up.append($('<p>').text(
'For one soldier or several named on the same document. Review proposed facts and assign each to the correct profile before Apply.'
'For one soldier or a multi-person document (e.g. IPW team special orders). OCR groups people, matches by ASN when possible, and can Apply team membership to every matched profile in one pass.'
));
));
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' });