OCR: person cards clarify scan vs wiki page vs team update
OCR roster table: who / wiki page / what changes on that profile
Line 1,551: Line 1,551:
var grouped = groupFactsByPerson(proposed);
var grouped = groupFactsByPerson(proposed);
var lastGroup = null;
var lastGroup = null;
var personIndex = 0;
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) {
Line 1,567: Line 1,575:
});
});
var teamFact = teamFacts[0];
var teamFact = teamFacts[0];
var teamDone = teamFact && teamFact.already_on_page;
var teamDone = !!(teamFact && teamFact.already_on_page);
var teamReady = teamFact && !teamFact.already_on_page && !!(teamFact.value || '').trim();
var teamReady = !!(teamFact && !teamFact.already_on_page && (teamFact.value || '').trim());
var included = !!(title && teamReady && teamFact.selected !== false);
var included = !!(title && teamReady && teamFact.selected !== false);
var teamLabel = (teamFact && (teamFact.value || '').trim())
? ('IPW ' + String(teamFact.value).replace(/^IPW\s+/i, ''))
: (meta.team || 'this team');


if (groupId && groupId !== lastGroup) {
if (groupId && groupId !== lastGroup) {
Line 1,579: Line 1,590:
return (grouped.byPerson[k][0] || {}).group_id === groupId;
return (grouped.byPerson[k][0] || {}).group_id === groupId;
}).length;
}).length;
$list.append($('<h3>', { 'class': 'ocr-group-heading' }).text(
$tbody.append($('<tr>', { 'class': 'ocr-group-heading-row' }).append(
groupLabel + ' — ' + inGroup + ' people on this document'
$('<td>', { colspan: 4 }).text(
groupLabel + ' — ' + inGroup + ' people on this document'
)
));
));
}
}


personIndex += 1;
var $card = $('<tr>', {
var $card = $('<div>', {
'class': 'ocr-person-card' +
'class': 'ocr-person-card' +
(title ? ' ocr-person-matched' : ' ocr-person-unresolved') +
(title ? ' ocr-person-matched' : ' ocr-person-unresolved') +
Line 1,592: Line 1,604:
});
});


$card.append($('<div>', { 'class': 'ocr-card-step' }).text(
'Person ' + personIndex + ' of ' + grouped.order.length + ' on the scan'
));
/* 1. Who is on the document (read-only identity) */
var $who = $('<div>', { 'class': 'ocr-card-who' });
$who.append($('<div>', { 'class': 'ocr-card-who-label' }).text('On the document'));
$who.append($('<div>', { 'class': 'ocr-card-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 (meta.team) {
whoBits.push(meta.team);
}
if (whoBits.length) {
$who.append($('<div>', { 'class': 'ocr-card-meta' }).text(whoBits.join(' · ')));
}
$who.append($('<p>', { 'class': 'ocr-card-hint' }).text(
'Rank, role, and ASN here are from the scan (for matching). They are not written to the wiki unless you open “Also update other fields.”'
));
$card.append($who);
/* 2. Which wiki profile */
var $match = $('<div>', { 'class': 'ocr-card-match' });
$match.append($('<div>', { 'class': 'ocr-card-who-label' }).text('Wiki soldier page'));
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);
}
var $tgtShared = $('<input>', {
var $tgtShared = $('<input>', {
type: 'text',
type: 'text',
'class': 'ocr-person-title',
'class': 'ocr-person-title',
value: title,
value: title,
placeholder: 'Type surname or ASN to find their page…'
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' });
 
function syncPrimaryChecks() {
var on = $include.prop('checked') && !!$.trim($tgtShared.val() || '') && !teamDone;
$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 (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 || '';
title = t || '';
$tgtShared.val(title);
$tgtShared.val(title);
$card.find('.ocr-fact-target').val(title);
$card.find('.ocr-fact-target').val(title);
$pageLink.empty();
if (title) {
if (title) {
$card.removeClass('ocr-person-unresolved').addClass('ocr-person-matched');
$card.removeClass('ocr-person-unresolved').addClass('ocr-person-matched');
$matchStatus.empty().append(
$pageLink.append($('<a>', {
$('<span>').text('Will update: '),
href: mw.util.getUrl(title),
$('<a>', { href: mw.util.getUrl(title), text: title, target: '_blank' })
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('Pick the matching soldier page before applying.');
$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();
syncPrimaryChecks();
}
}
if (onChange) {
refreshOutcome();
if (!silent && onChange) {
onChange();
onChange();
}
}
}
}
function syncPrimaryChecks() {
 
var on = $include.prop('checked') && !!$.trim($tgtShared.val() || '');
var $do = $('<td>', { 'class': 'ocr-col-do' });
$card.find('.ocr-fact-primary.ocr-fact-writable input[type="checkbox"]').prop('checked', on);
$do.append($include);
if (!on) {
 
$card.find('.ocr-fact-secondary.ocr-fact-writable input[type="checkbox"]').prop('checked', false);
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);
}
}
$match.append($matchStatus);
if (whoBits.length) {
if (title) {
$who.append($('<div>', { 'class': 'ocr-scan-bits' }).text(
setMatch(title);
'From the scan (not written): ' + whoBits.join(' · ')
} else {
));
$matchStatus.text('Not matched yet — search below or pick a suggestion.');
}
}
$match.append($tgtShared);
 
$match.append($suggest);
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,693: Line 1,752:
}
}
});
});
$match.append($pick);
$page.append($pick);
}
}
$card.append($match);


/* 3. What changes on that profile (team is the only primary action) */
var $action = $('<td>', { 'class': 'ocr-col-action' });
var $update = $('<div>', { 'class': 'ocr-card-update' });
$action.append($outcome);
$update.append($('<div>', { 'class': 'ocr-card-who-label' }).text('Change on their profile'));
var $includeRow = $('<label>', { 'class': 'ocr-include-row' });
$includeRow.append($include);


var $changes = $('<div>', { 'class': 'ocr-card-changes' });
var $store = $('<div>', { 'class': 'ocr-fact-store' });
if (teamFact) {
if (teamFact) {
var $teamRow = renderChangeRow(teamFact, job, {
var $teamRow = renderChangeRow(teamFact, job, {
hideTargetVisible: true,
plainSentence: true,
plainSentence: true,
forceSelected: included && !teamDone
forceSelected: included && !teamDone
});
});
$teamRow.find('.ocr-fact-target').val(title);
$teamRow.find('.ocr-fact-target').val(title);
$teamRow.find('input[type="checkbox"]').first().addClass('ocr-primary-check').hide();
$teamRow.find('input[type="checkbox"]').first().addClass('ocr-primary-check');
$changes.append($teamRow);
$store.append($teamRow);
var sentence = profileUpdateSentence(teamFact);
}
$includeRow.append(document.createTextNode(' ' + (teamDone ? sentence : ('Apply this: ' + sentence))));
notes.forEach(function (fact) {
} else {
var $nrow = renderChangeRow(fact, job, {});
$includeRow.append(document.createTextNode(' No team number found for this person yet.'));
$nrow.find('.ocr-fact-target').val(title);
$include.prop('disabled', true);
$store.append($nrow);
});
$action.append($store);
 
if (secondary.length) {
var $moreFields = $('<details>', { 'class': 'ocr-card-more' });
$moreFields.append($('<summary>').text(
'Also write rank / ASN / role (' + secondary.length + ') — off unless you check them'
));
secondary.forEach(function (fact) {
var $row = renderChangeRow(fact, job, { forceSelected: false });
$row.find('.ocr-fact-target').val(title);
$moreFields.append($row);
});
$action.append($moreFields);
}
}
$update.append($includeRow);
$update.append($changes);


$include.on('change', function () {
$include.on('change', function () {
if ($include.prop('checked') && !$.trim($tgtShared.val() || '')) {
if ($include.prop('checked') && !$.trim($tgtShared.val() || '')) {
$include.prop('checked', false);
$include.prop('checked', false);
$matchStatus.text('Match a soldier page first, then check Apply.');
$matchStatus.text('Match a soldier page first, then check Write?.');
refreshOutcome();
return;
return;
}
}
if ($include.prop('checked') && teamDone) {
if ($include.prop('checked') && teamDone) {
$include.prop('checked', false);
$include.prop('checked', false);
refreshOutcome();
return;
return;
}
}
syncPrimaryChecks();
syncPrimaryChecks();
refreshOutcome();
if (onChange) {
if (onChange) {
onChange();
onChange();
Line 1,738: Line 1,807:
});
});


if (secondary.length) {
$card.append($do, $who, $page, $action);
var $moreFields = $('<details>', { 'class': 'ocr-card-more' });
$moreFields.append($('<summary>').text(
'Also update other fields on their page (' + secondary.length + ') — off by default'
));
secondary.forEach(function (fact) {
var $row = renderChangeRow(fact, job, {
hideTargetVisible: true,
forceSelected: false
});
$row.find('.ocr-fact-target').val(title);
$moreFields.append($row);
});
$update.append($moreFields);
}
 
if (notes.length) {
var $moreNotes = $('<details>', { 'class': 'ocr-card-more' });
$moreNotes.append($('<summary>').text('OCR line / teammates (not written)'));
notes.forEach(function (fact) {
var $row = renderChangeRow(fact, job, { hideTargetVisible: true });
$row.find('.ocr-fact-target').val(title);
$moreNotes.append($row);
});
$update.append($moreNotes);
}
 
$card.append($update);
$card.on('change', 'input[type="checkbox"], input.ocr-fact-value', function (ev) {
$card.on('change', 'input[type="checkbox"], input.ocr-fact-value', function (ev) {
if ($(ev.target).hasClass('ocr-include')) {
if ($(ev.target).hasClass('ocr-include')) {
return;
return;
}
}
refreshOutcome();
if (onChange) {
if (onChange) {
onChange();
onChange();
}
}
});
});
$list.append($card);
$tbody.append($card);
setMatch(title, true);
});
});
$list.append($table);
}
}


Line 1,873: Line 1,919:
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' + (job.multi_person ? ' ocr-review-roster' : '') });
$panel.append($('<p>').append(
if (job.multi_person) {
$('<strong>').text(job.multi_person ? 'One card per person on the scan' : 'Confirm changes, then Apply'),
$panel.append($('<p>').append(
document.createTextNode(
$('<strong>').text('Each row is one person'),
job.multi_person
document.createTextNode(
? ' — for each person: confirm the wiki page, then check what to write on their profile (usually just Team). Nothing is saved until Apply.'
' — 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.'
: ' — nothing is saved until you click Apply.'
)
)
));
));
} else {
$panel.append($('<p>').append(
$('<strong>').text('Confirm changes, then Apply'),
document.createTextNode(' — nothing is saved until you click Apply.')
));
}


if (job.multi_person && job.people_summary) {
if (job.multi_person && job.people_summary) {
var ps = job.people_summary;
var ps = job.people_summary;
$panel.append($('<p>', { 'class': 'ocr-people-summary' }).text(
var $banner = $('<div>', { 'class': 'ocr-outcome-banner' });
(ps.detected || 0) + ' people on this document · ' +
$banner.append($('<strong>').text(
(ps.matched || 0) + ' matched to wiki pages · ' +
(ps.detected || 0) + ' people on this document'
(ps.unresolved || 0) + ' still need a match · ' +
(ps.already_on_page || 0) + ' already have this team' +
(ps.teams && ps.teams.length ? (' · teams ' + ps.teams.join(', ')) : '') +
(ps.inferred_teams && ps.inferred_teams.length ? (' · inferred ' + ps.inferred_teams.join(', ')) : '')
));
));
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 {
$panel.append($('<p>', { 'class': 'ocr-fact-meta' }).text(
$panel.append($('<p>', { 'class': 'ocr-fact-meta' }).text(
Line 1,942: Line 2,002:


var $previewBox = $('<div>', { 'class': 'ocr-apply-preview' });
var $previewBox = $('<div>', { 'class': 'ocr-apply-preview' });
$previewBox.append($('<strong>').text('What will change on soldier pages'));
$previewBox.append($('<strong>').text('Soldier pages that will be edited'));
$previewBox.append($('<p>', { 'class': 'ocr-fact-meta' }).text(
$previewBox.append($('<p>', { 'class': 'ocr-fact-meta ocr-preview-hint' }).text(
job.multi_person
job.multi_person
? 'Only checked “Apply this” team updates (and any extra fields you opened) are listed here.'
? 'Grouped by wiki page. Rank/role/ASN from the scan are not listed here unless you check them under a row.'
: 'Checked items below will be written on Apply.'
: 'Checked items below will be written on Apply.'
));
));
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 props = collectProposed();
var props = collectProposed();
var lines = [];
var byPage = {};
props.forEach(function (p) {
props.forEach(function (p) {
if (!p.selected || !isWritableFact(p) || p.already_on_page) {
if (!p.selected || !isWritableFact(p) || p.already_on_page) {
Line 1,962: Line 2,023:
return;
return;
}
}
var who = personDisplayName(p);
if (!byPage[page]) {
lines.push(who + ' → page “' + page + '”: ' + changeLineForFact(p));
byPage[page] = [];
}
byPage[page].push(p);
});
});
var pages = Object.keys(byPage);
$previewUl.empty();
$previewUl.empty();
if (!lines.length) {
if (!pages.length) {
$previewCount.text('');
$previewUl.append($('<li>', { 'class': 'ocr-fact-meta' }).text(
$previewUl.append($('<li>', { 'class': 'ocr-fact-meta' }).text(
job.multi_person
job.multi_person
? 'Nothing selected yet. Match a person and check “Apply this” on their card.'
? 'Nothing will be written on Apply. Check Write? on a row (after matching a wiki page) to add that person.'
: 'Nothing selected yet.'
: 'Nothing selected yet.'
));
));
} else {
} else {
lines.forEach(function (ln) {
$previewCount.text(
$previewUl.append($('<li>').text(ln));
'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);
});
});
}
}
}
}


$facts.append($previewBox);
if (job.multi_person) {
if (job.multi_person) {
var $toolbar = $('<div>', { 'class': 'ocr-multi-toolbar' });
var $toolbar = $('<div>', { 'class': 'ocr-multi-toolbar' });
var $selMatched = $('<button>', { type: 'button', text: 'Apply team for all matched' });
var $selMatched = $('<button>', { type: 'button', text: 'Write team for every matched person who still needs it' });
var $deselUnres = $('<button>', { type: 'button', text: 'Clear unmatched' });
var $deselUnres = $('<button>', { type: 'button', text: 'Clear unmatched' });
$toolbar.append($selMatched, $deselUnres);
$toolbar.append($selMatched, $deselUnres);
Line 1,989: Line 2,065:
$list.find('.ocr-person-matched:not(.ocr-person-done)').each(function () {
$list.find('.ocr-person-matched:not(.ocr-person-done)').each(function () {
var $card = $(this);
var $card = $(this);
$card.find('.ocr-include').prop('checked', true).trigger('change');
$card.find('.ocr-include').prop('disabled', false).prop('checked', true).trigger('change');
});
});
refreshPreview();
refreshPreview();
Line 2,001: Line 2,077:
}
}
$facts.append($list);
$facts.append($list);
$facts.append($previewBox);


/* Advanced: OCR text, doc type, gallery, overwrite, rebuild */
/* Advanced: OCR text, doc type, gallery, overwrite, rebuild */
Line 2,187: Line 2,262:


$apply.on('click', function () {
$apply.on('click', function () {
if (!job.identity_ok && !$forceId.find('input').prop('checked')) {
if (!job.multi_person && !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,366: Line 2,441:
$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 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.'
'For a roster, review is a table: who is on the scan, which wiki page, and what would be written on that profile (usually Team). Rank, role, and ASN are matching cues, not automatic edits.'
));
));
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' });