feat: add Legal Translation tool (translate.php)
New dedicated tool for translating Norwegian legal documents (Barnevernet letters, court decisions, correspondence) into the user's chosen language with legal-terminology annotations. - translate.php: new tool page with source/target language selectors, 4-way UI lang switcher, file upload, doc picker, streaming results - api/translate.php: NDJSON streaming endpoint; Azure GPT-4o-mini with legal-aware prompt that preserves Norwegian statute refs verbatim and annotates terms with no target-language equivalent; 2-credit cost - assets/js/translate.js: form handler, NDJSON stream reader, copy button - assets/css/tools.css: .lt-* styles for translation result + annotations - includes/i18n.php: 22 lt_* keys × 4 languages; translate entry in nav - includes/FreeTier.php: translate → 2 credits - includes/CaseResults.php + case-result.php: translate in eligible tools, toolLabel, toolIcon, deriveTitle, rendering block, rerun map Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,336 @@
|
||||
/**
|
||||
* translate.js — Legal Translation tool handler.
|
||||
*
|
||||
* Single-pass: POST text + language pair → Azure GPT-4o-mini → translated text
|
||||
* with optional legal-term annotations. Streams NDJSON.
|
||||
*
|
||||
* UI strings from window.DBN_LT_I18N (populated by translate.php inline script).
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
// ── i18n helper ───────────────────────────────────────────────────────────
|
||||
var I18N = window.DBN_LT_I18N || {};
|
||||
function t(key, vars) {
|
||||
var s = (I18N && I18N[key]) || key;
|
||||
if (vars) {
|
||||
Object.keys(vars).forEach(function (k) {
|
||||
s = s.split('{' + k + '}').join(String(vars[k]));
|
||||
});
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
// ── Element refs ──────────────────────────────────────────────────────────
|
||||
var form = document.getElementById('ltForm');
|
||||
var runBtn = document.getElementById('ltRunButton');
|
||||
var statusEl = document.getElementById('ltStatus');
|
||||
var resultsEl = document.getElementById('ltResults');
|
||||
var textarea = document.getElementById('ltInput');
|
||||
|
||||
var uploadZone = document.getElementById('ltUploadZone');
|
||||
var uploadInput = document.getElementById('ltUploadInput');
|
||||
var uploadPrompt = document.getElementById('ltUploadPrompt');
|
||||
var uploadFileInfo = document.getElementById('ltUploadFileInfo');
|
||||
var uploadFileList = document.getElementById('ltUploadFileList');
|
||||
var uploadClear = document.getElementById('ltUploadClear');
|
||||
|
||||
// ── State ─────────────────────────────────────────────────────────────────
|
||||
var _extractedFiles = [];
|
||||
var _currentLang = window.DBN_LT_LANG || window.DBN_CURRENT_LANG || 'no';
|
||||
var _lastResult = null;
|
||||
|
||||
// ── Lang switcher ─────────────────────────────────────────────────────────
|
||||
document.querySelectorAll('.lt-lang-btn').forEach(function (btn) {
|
||||
btn.addEventListener('click', function () {
|
||||
var lang = btn.dataset.lang || 'no';
|
||||
if (lang === _currentLang) return;
|
||||
var url = new URL(window.location.href);
|
||||
url.searchParams.set('lang', lang);
|
||||
window.location.href = url.toString();
|
||||
});
|
||||
});
|
||||
|
||||
// ── File upload ───────────────────────────────────────────────────────────
|
||||
if (uploadZone) {
|
||||
uploadZone.addEventListener('dragover', function (e) {
|
||||
e.preventDefault();
|
||||
uploadZone.classList.add('is-drag-over');
|
||||
});
|
||||
uploadZone.addEventListener('dragleave', function (e) {
|
||||
if (!uploadZone.contains(e.relatedTarget)) {
|
||||
uploadZone.classList.remove('is-drag-over');
|
||||
}
|
||||
});
|
||||
uploadZone.addEventListener('drop', function (e) {
|
||||
e.preventDefault();
|
||||
uploadZone.classList.remove('is-drag-over');
|
||||
if (e.dataTransfer && e.dataTransfer.files.length) {
|
||||
handleFiles(e.dataTransfer.files);
|
||||
}
|
||||
});
|
||||
var browseLabel = uploadZone.querySelector('label[for="' + (uploadInput && uploadInput.id) + '"]');
|
||||
if (browseLabel) {
|
||||
browseLabel.addEventListener('click', function (e) { e.stopPropagation(); });
|
||||
}
|
||||
if (uploadInput) {
|
||||
uploadInput.addEventListener('click', function (e) { e.stopPropagation(); });
|
||||
}
|
||||
uploadZone.addEventListener('click', function (e) {
|
||||
if (e.target === uploadClear || (uploadClear && uploadClear.contains(e.target))) return;
|
||||
if (e.target === uploadInput) return;
|
||||
var lbl = e.target.closest && e.target.closest('label');
|
||||
if (lbl && lbl.getAttribute('for') === (uploadInput && uploadInput.id)) return;
|
||||
if (uploadInput) uploadInput.click();
|
||||
});
|
||||
}
|
||||
if (uploadInput) {
|
||||
uploadInput.addEventListener('change', function () {
|
||||
if (uploadInput.files && uploadInput.files.length) handleFiles(uploadInput.files);
|
||||
});
|
||||
}
|
||||
if (uploadClear) {
|
||||
uploadClear.addEventListener('click', function (e) {
|
||||
e.stopPropagation();
|
||||
resetUpload();
|
||||
});
|
||||
}
|
||||
|
||||
function resetUpload() {
|
||||
_extractedFiles = [];
|
||||
if (uploadInput) uploadInput.value = '';
|
||||
if (uploadPrompt) uploadPrompt.classList.remove('is-hidden');
|
||||
if (uploadFileInfo) uploadFileInfo.classList.add('is-hidden');
|
||||
if (uploadFileList) uploadFileList.innerHTML = '';
|
||||
}
|
||||
|
||||
function handleFiles(fileList) {
|
||||
var files = Array.from(fileList).slice(0, 5);
|
||||
if (!files.length) return;
|
||||
|
||||
setStatus(t('extractingFiles', { n: files.length }));
|
||||
setBusy(true);
|
||||
|
||||
var promises = files.map(function (file) {
|
||||
var fd = new FormData();
|
||||
fd.append('file', file);
|
||||
return fetch('api/extract.php', { method: 'POST', credentials: 'same-origin', body: fd })
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (data) {
|
||||
if (!data.ok) throw new Error(data.error || 'Extraction failed for ' + file.name);
|
||||
return { name: file.name, text: data.text || '', chars: data.chars || 0 };
|
||||
});
|
||||
});
|
||||
|
||||
Promise.all(promises)
|
||||
.then(function (results) {
|
||||
_extractedFiles = results;
|
||||
renderFileList(results);
|
||||
setStatus('');
|
||||
setBusy(false);
|
||||
})
|
||||
.catch(function (err) {
|
||||
setStatus(t('errorPrefix') + ': ' + err.message);
|
||||
setBusy(false);
|
||||
});
|
||||
}
|
||||
|
||||
function renderFileList(files) {
|
||||
if (!uploadFileList) return;
|
||||
uploadFileList.innerHTML = files.map(function (f) {
|
||||
return '<li><span class="upload-filename">' + esc(f.name) + '</span>'
|
||||
+ ' <span class="upload-chars">' + f.chars.toLocaleString() + ' chars</span></li>';
|
||||
}).join('');
|
||||
if (uploadPrompt) uploadPrompt.classList.add('is-hidden');
|
||||
if (uploadFileInfo) uploadFileInfo.classList.remove('is-hidden');
|
||||
}
|
||||
|
||||
// ── Form submission ───────────────────────────────────────────────────────
|
||||
if (form) {
|
||||
form.addEventListener('submit', function (e) {
|
||||
e.preventDefault();
|
||||
runTranslation();
|
||||
});
|
||||
}
|
||||
|
||||
async function runTranslation() {
|
||||
var pastedText = textarea ? textarea.value.trim() : '';
|
||||
var fileText = _extractedFiles.map(function (f) { return f.text; }).join('\n\n---\n\n');
|
||||
var combined = [fileText, pastedText].filter(Boolean).join('\n\n---\n\n');
|
||||
|
||||
var docIdsEl = document.getElementById('docPickerIds');
|
||||
var rawDocIds = docIdsEl ? docIdsEl.value.trim() : '';
|
||||
var docIds = rawDocIds ? rawDocIds.split(',').map(Number).filter(Boolean) : [];
|
||||
|
||||
if (!combined && !docIds.length) {
|
||||
setStatus(t('needInput'));
|
||||
return;
|
||||
}
|
||||
|
||||
var sourceLang = (document.querySelector('input[name="ltSourceLang"]:checked') || {}).value || 'no';
|
||||
var targetLang = (document.querySelector('input[name="ltTargetLang"]:checked') || {}).value || 'en';
|
||||
|
||||
if (sourceLang === targetLang) {
|
||||
setStatus(t('sameLangError'));
|
||||
return;
|
||||
}
|
||||
|
||||
var docType = (document.querySelector('input[name="ltDocType"]:checked') || {}).value || 'auto';
|
||||
|
||||
var payload = {
|
||||
text: combined,
|
||||
language: _currentLang,
|
||||
source_lang: sourceLang,
|
||||
target_lang: targetLang,
|
||||
doc_type: docType,
|
||||
};
|
||||
if (docIds.length) payload.doc_ids = docIds;
|
||||
|
||||
_lastResult = null;
|
||||
setBusy(true);
|
||||
setStatus(t('translatingStatus'));
|
||||
showBusy();
|
||||
|
||||
try {
|
||||
var resp = await fetch('api/translate.php', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!resp.ok || !resp.body) {
|
||||
throw new Error(t('serverReturned') + ' ' + resp.status);
|
||||
}
|
||||
|
||||
var reader = resp.body.getReader();
|
||||
var decoder = new TextDecoder();
|
||||
var buffer = '';
|
||||
|
||||
while (true) {
|
||||
var _ref = await reader.read();
|
||||
if (_ref.done) break;
|
||||
buffer += decoder.decode(_ref.value, { stream: true });
|
||||
var lines = buffer.split('\n');
|
||||
buffer = lines.pop();
|
||||
for (var i = 0; i < lines.length; i++) {
|
||||
var line = lines[i].trim();
|
||||
if (!line) continue;
|
||||
var data;
|
||||
try { data = JSON.parse(line); } catch (_) { continue; }
|
||||
handleEvent(data, payload);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
showError(err.message || 'Request failed.');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
setStatus('');
|
||||
}
|
||||
}
|
||||
|
||||
function handleEvent(data, payload) {
|
||||
if (data.event === 'progress') {
|
||||
setStatus(data.detail || '');
|
||||
} else if (data.event === 'final') {
|
||||
_lastResult = data.result || {};
|
||||
renderResult(_lastResult, payload);
|
||||
if (typeof window.dbnShowSaveResultButton === 'function') {
|
||||
window.dbnShowSaveResultButton(
|
||||
'translate',
|
||||
payload || {},
|
||||
_lastResult,
|
||||
{
|
||||
model: (_lastResult && _lastResult.model) || 'gpt-4o-mini',
|
||||
latency_ms: (_lastResult && _lastResult.latency_ms) || 0,
|
||||
},
|
||||
resultsEl
|
||||
);
|
||||
}
|
||||
} else if (data.event === 'error') {
|
||||
showError(data.message || data.error || 'An error occurred.');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Result rendering ──────────────────────────────────────────────────────
|
||||
function showBusy() {
|
||||
if (!resultsEl) return;
|
||||
resultsEl.innerHTML = '<div class="lt-busy"><p>' + esc(t('translatingStatus')) + '</p></div>';
|
||||
}
|
||||
|
||||
function renderResult(result, payload) {
|
||||
if (!resultsEl) return;
|
||||
|
||||
var translatedText = (result.translated_text || '').replace(/\n/g, '<br>');
|
||||
var annotations = Array.isArray(result.annotations) ? result.annotations : [];
|
||||
var disclaimer = result.disclaimer || t('disclaimer');
|
||||
var sourceLang = (payload && payload.source_lang) || '';
|
||||
var targetLang = (payload && payload.target_lang) || '';
|
||||
|
||||
var copyId = 'ltCopyBtn-' + Date.now();
|
||||
|
||||
var html = '<section class="lt-result result-section">'
|
||||
+ '<div class="lt-result__head">'
|
||||
+ '<h3>' + esc(t('resultTitle')) + '</h3>'
|
||||
+ (sourceLang && targetLang
|
||||
? '<span class="lt-lang-pair">' + esc(sourceLang.toUpperCase()) + ' → ' + esc(targetLang.toUpperCase()) + '</span>'
|
||||
: '')
|
||||
+ '<button type="button" id="' + copyId + '" class="lt-copy-btn secondary-btn">'
|
||||
+ esc(t('copyButton')) + '</button>'
|
||||
+ '</div>'
|
||||
+ '<div class="lt-translated-text">' + translatedText + '</div>';
|
||||
|
||||
if (annotations.length) {
|
||||
html += '<div class="lt-annotations"><h4>' + esc(t('annotationsTitle')) + '</h4>';
|
||||
annotations.forEach(function (ann) {
|
||||
if (!ann || !ann.term) return;
|
||||
html += '<div class="lt-annotation">'
|
||||
+ '<strong>' + esc(ann.term) + '</strong>'
|
||||
+ (ann.explanation ? ' — ' + esc(ann.explanation) : '')
|
||||
+ '</div>';
|
||||
});
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
if (disclaimer) {
|
||||
html += '<p class="disclaimer-note">' + esc(disclaimer) + '</p>';
|
||||
}
|
||||
|
||||
html += '</section>';
|
||||
resultsEl.innerHTML = html;
|
||||
|
||||
var copyBtn = document.getElementById(copyId);
|
||||
if (copyBtn) {
|
||||
copyBtn.addEventListener('click', function () {
|
||||
var plain = (result.translated_text || '').replace(/<br\s*\/?>/gi, '\n');
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
navigator.clipboard.writeText(plain).then(function () {
|
||||
copyBtn.textContent = t('copyDone');
|
||||
setTimeout(function () { copyBtn.textContent = t('copyButton'); }, 2000);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function showError(msg) {
|
||||
if (resultsEl) {
|
||||
resultsEl.innerHTML = '<div class="empty-state"><h3>' + esc(t('errorPrefix')) + '</h3><p>' + esc(msg) + '</p></div>';
|
||||
}
|
||||
setStatus('');
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
function setBusy(on) {
|
||||
if (runBtn) runBtn.disabled = on;
|
||||
if (runBtn) runBtn.textContent = on ? t('runButtonBusy') : t('runButton');
|
||||
}
|
||||
function setStatus(msg) {
|
||||
if (statusEl) statusEl.textContent = msg;
|
||||
}
|
||||
function esc(s) {
|
||||
return String(s == null ? '' : s)
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/"/g, '"').replace(/'/g, ''');
|
||||
}
|
||||
}());
|
||||
Reference in New Issue
Block a user