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 publicPreviewUrl(job) {
function publicAssetUrl(path) {
if (!job || !job.preview_url) {
if (!path) {
return '';
return '';
}
}
// Prefer sidecar public URL from config when available
var base = (window.__ocrPublicUrl || '').replace(/\/$/, '');
var base = (window.__ocrPublicUrl || '').replace(/\/$/, '');
if (base) {
if (base) {
return base + job.preview_url;
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 prevUrl = publicPreviewUrl(job);
var pages = job.pages || [];
if (prevUrl) {
if (pages.length > 1) {
$prev.append($('<img>', { src: prevUrl, alt: 'Document preview' }));
$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) {
var $row = $('<div>', { 'class': 'ocr-fact-row', 'data-id': fact.id });
if (!shown[fact.id]) {
var $check = $('<input>', { type: 'checkbox', checked: !!fact.selected });
$list.append(renderFactRow(fact, job));
var $label = $('<span>').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 $meta = $('<span>', { 'class': 'ocr-fact-meta' }).text(
(fact.kind || '') + ' · conf ' + (fact.confidence || 0) + '%'
);
$row.append($check, $label, $val, $tgt, $meta);
$list.append($row);
});
});
$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.append($('<p>', { 'class': 'ocr-review-status' }).text('Running OCR…'));
$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 > 90) {
if (tries > 180) {
$host.empty().append($('<p>').text('OCR timed out. Try again.'));
$host.empty().append($('<p>').text('OCR timed out. Open it later from the review queue.'));
return;
return;
}
}
$host.find('.ocr-review-status').text('Running OCR… (' + job.status + ')');
var pct = typeof job.progress === 'number' ? job.progress : 0;
window.setTimeout(tick, 2000);
$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 (resp) {
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 = resp.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);