Files
dobetternorge-tools/assets/js/korrespond.js
T
daveadmin 5d8ae6b447 Korrespond: add Refine pass with jurisdiction-scoped formal citations
After the first draft is rendered, a "Refine with citations" panel offers a
3rd-pass rewrite scoped to the user's choice of Norwegian law, ECHR (EMK +
HUDOC case law), or both. Refine pulls fresh corpus chunks limited to the
chosen jurisdiction's slices, rewrites inline cites in formal style ("jf.
forvaltningsloven § 17", "jf. Strand Lobben m.fl. mot Norge, EMD-37283/13,
§§ 207–214"), and appends a Rettskilder block listing every authority.
Hard-RAG grounding carries through — refine cannot cite anything that
wasn't retrieved. Costs 1 additional credit; the original draft stays in
place and the refined version appears below it.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 11:50:36 +02:00

646 lines
27 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* korrespond.js — page-scoped UI for /korrespond.php
Three-pass flow: Pass 1 may return clarify questions; Pass 2 returns Norwegian
+ working-language drafts with verified law citations. Pass 3 (opt-in) refines
the draft with jurisdiction-scoped formal citations + Rettskilder appendix.
*/
(function () {
'use strict';
const els = {};
let lang = window.DBN_TOOLS_LANG || localStorage.getItem('dbn-ui-lang') || 'en';
let uploadFiles = [];
let lastClassify = null;
let lastFinal = null;
let pendingClarifications = {};
const LANG_LABELS = { en: 'English', no: 'Norsk', uk: 'Українська', pl: 'Polski' };
document.addEventListener('DOMContentLoaded', () => {
if (document.body.dataset.activeTool !== 'korrespond') return;
Object.assign(els, {
form: document.getElementById('korrForm'),
status: document.getElementById('korrStatus'),
runButton: document.getElementById('korrRunButton'),
results: document.getElementById('korrResults'),
langButtons: Array.from(document.querySelectorAll('#korrLangSwitcher .lang-btn')),
modeRadios: Array.from(document.querySelectorAll('input[name="korrMode"]')),
bodySelect: document.getElementById('korrBody'),
outputRadios: Array.from(document.querySelectorAll('input[name="korrOutput"]')),
toneRadios: Array.from(document.querySelectorAll('input[name="korrTone"]')),
caseRef: document.getElementById('korrCaseRef'),
where: document.getElementById('korrWhere'),
deadline: document.getElementById('korrDeadline'),
parties: document.getElementById('korrParties'),
narrative: document.getElementById('korrNarrative'),
goal: document.getElementById('korrGoal'),
goalChips: Array.from(document.querySelectorAll('#korrGoalChips .korr-chip')),
uploadZone: document.getElementById('korrUploadZone'),
uploadInput: document.getElementById('korrUploadInput'),
uploadPrompt: document.getElementById('korrUploadPrompt'),
uploadFileInfo: document.getElementById('korrUploadFileInfo'),
uploadFileList: document.getElementById('korrUploadFileList'),
uploadClear: document.getElementById('korrUploadClear'),
clarifyPanel: document.getElementById('korrClarifyPanel'),
clarifyList: document.getElementById('korrClarifyList'),
clarifyContinue:document.getElementById('korrClarifyContinue'),
clarifyForce: document.getElementById('korrClarifyForce'),
});
if (!els.form) return;
bindLang();
bindGoalChips();
bindUpload();
bindClarify();
els.form.addEventListener('submit', (e) => { e.preventDefault(); runRequest(false); });
});
// ── Language switcher ───────────────────────────────────────────────────────
function bindLang() {
els.langButtons.forEach((b) => {
b.classList.toggle('is-active', b.dataset.lang === lang);
b.addEventListener('click', () => {
els.langButtons.forEach((x) => x.classList.remove('is-active'));
b.classList.add('is-active');
lang = b.dataset.lang || 'en';
localStorage.setItem('dbn-ui-lang', lang);
});
});
}
function bindGoalChips() {
els.goalChips.forEach((chip) => {
chip.addEventListener('click', () => {
els.goal.value = chip.dataset.goal || '';
els.goalChips.forEach((c) => c.classList.remove('is-active'));
chip.classList.add('is-active');
});
});
}
// ── File upload ─────────────────────────────────────────────────────────────
function bindUpload() {
if (!els.uploadZone) return;
const onFiles = (fileList) => {
const files = Array.from(fileList || []).slice(0, 4);
if (uploadFiles.length + files.length > 4) {
setStatus('At most 4 files can be uploaded per request.', 'error');
return;
}
files.forEach((f) => {
if (f.size > 8 * 1024 * 1024) {
setStatus(`${f.name} exceeds the 8 MB limit.`, 'error');
return;
}
const ext = (f.name.split('.').pop() || '').toLowerCase();
if (!['pdf', 'docx', 'txt'].includes(ext)) {
setStatus(`${f.name} is not a supported file type (PDF, DOCX, TXT).`, 'error');
return;
}
uploadFiles.push(f);
});
renderUploadList();
};
els.uploadInput.addEventListener('change', (e) => onFiles(e.target.files));
els.uploadZone.addEventListener('dragover', (e) => { e.preventDefault(); els.uploadZone.classList.add('is-drop'); });
els.uploadZone.addEventListener('dragleave', () => els.uploadZone.classList.remove('is-drop'));
els.uploadZone.addEventListener('drop', (e) => {
e.preventDefault();
els.uploadZone.classList.remove('is-drop');
onFiles(e.dataTransfer?.files);
});
els.uploadClear?.addEventListener('click', () => {
uploadFiles = [];
els.uploadInput.value = '';
renderUploadList();
});
}
function renderUploadList() {
if (!uploadFiles.length) {
els.uploadFileInfo.classList.add('is-hidden');
els.uploadPrompt.classList.remove('is-hidden');
return;
}
els.uploadPrompt.classList.add('is-hidden');
els.uploadFileInfo.classList.remove('is-hidden');
els.uploadFileList.innerHTML = uploadFiles.map((f) => {
const kb = (f.size / 1024).toFixed(0);
return `<li><span class="upload-filename">${esc(f.name)}</span><span class="upload-chars">${kb} KB</span></li>`;
}).join('');
}
// ── Clarify panel ───────────────────────────────────────────────────────────
function bindClarify() {
els.clarifyContinue?.addEventListener('click', () => {
pendingClarifications = collectClarifications();
hideClarify();
runRequest(false);
});
els.clarifyForce?.addEventListener('click', () => {
pendingClarifications = collectClarifications();
hideClarify();
runRequest(true);
});
}
function showClarify(questions) {
els.clarifyList.innerHTML = (questions || []).map((q, i) => `
<div class="korr-clarify-item">
<label for="clarify_${i}"><strong>${esc(q.question || '')}</strong></label>
<input type="text" id="clarify_${i}" data-key="${esc(q.key || '')}" placeholder="(optional)">
</div>
`).join('');
els.clarifyPanel.classList.remove('is-hidden');
els.clarifyPanel.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
function hideClarify() {
els.clarifyPanel.classList.add('is-hidden');
}
function collectClarifications() {
const inputs = els.clarifyList.querySelectorAll('input[data-key]');
const out = {};
inputs.forEach((inp) => {
const v = (inp.value || '').trim();
if (v) out[inp.dataset.key] = v;
});
return out;
}
// ── Submit ──────────────────────────────────────────────────────────────────
function getRadio(list) {
const checked = list.find((r) => r.checked);
return checked ? checked.value : '';
}
function buildPayload(forceDraft) {
const deadlines = [];
if (els.deadline.value.trim()) deadlines.push(els.deadline.value.trim());
return {
mode: getRadio(els.modeRadios) || 'initiate',
recipient_body: els.bodySelect.value || 'other',
output_type: getRadio(els.outputRadios) || 'email',
tone: getRadio(els.toneRadios) || 'neutral',
language: lang,
case_ref: els.caseRef.value.trim(),
where: els.where.value.trim(),
deadlines,
parties_text: els.parties.value.trim(),
narrative: els.narrative.value.trim(),
goal: els.goal.value.trim(),
clarifications: pendingClarifications,
force_draft: !!forceDraft,
};
}
async function runRequest(forceDraft) {
const payload = buildPayload(forceDraft);
if (!payload.recipient_body) {
setStatus('Pick a recipient body before drafting.', 'error');
return;
}
if (payload.mode === 'initiate' && !payload.narrative) {
setStatus('Describe the situation in "What happened" first.', 'error');
return;
}
if (payload.mode === 'reply' && !uploadFiles.length && !payload.narrative) {
setStatus('Reply mode needs the received letter (upload or paste it).', 'error');
return;
}
setStatus('Analyserer…', 'busy');
els.runButton.disabled = true;
els.results.innerHTML = `<div class="empty-state"><h3>Working…</h3><p>Pass 1 extracts facts. If anything is missing we'll ask for clarification. Otherwise Pass 2 runs hard-RAG retrieval + draft + self-check + translate.</p></div>`;
const form = new FormData();
form.append('payload', JSON.stringify(payload));
uploadFiles.forEach((f) => form.append('files[]', f));
let response;
try {
response = await fetch('api/korrespond.php', { method: 'POST', body: form, credentials: 'same-origin' });
} catch (err) {
setStatus(`Network error: ${err.message || err}`, 'error');
els.runButton.disabled = false;
return;
}
if (!response.ok || !response.body) {
if (response.status === 402 || response.status === 429) {
const d = await response.json().catch(() => ({}));
if (typeof window.dbnFreeTierError === 'function') window.dbnFreeTierError(response.status, d);
} else {
setStatus(`Request failed (${response.status}).`, 'error');
}
els.runButton.disabled = false;
return;
}
const creditsRemaining = response.headers.get('X-Credits-Remaining');
if (creditsRemaining !== null && typeof window.dbnUpdateCredits === 'function') {
window.dbnUpdateCredits(parseInt(creditsRemaining, 10));
}
const reader = response.body.getReader();
const decoder = new TextDecoder('utf-8');
let buffer = '';
let finalResult = null;
let errorEvent = null;
let clarifyEvent = null;
while (true) {
let chunk;
try { chunk = await reader.read(); }
catch (err) {
setStatus(`Stream error: ${err.message || err}`, 'error');
els.runButton.disabled = false;
return;
}
const { done, value } = chunk;
if (value) {
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop();
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
let evt; try { evt = JSON.parse(trimmed); } catch (_) { continue; }
if (!evt || !evt.event) continue;
if (evt.event === 'progress') { setStatus(evt.detail || 'Working…', 'busy'); continue; }
if (evt.event === 'start') { setStatus(`Started — ${evt.body} / ${evt.output_type}`, 'busy'); continue; }
if (evt.event === 'classify') { lastClassify = evt.result; renderClassifySummary(evt.result); continue; }
if (evt.event === 'retrieval'){ setStatus(`Hentet ${evt.sources_count} lovkilder for ${(evt.applicable_acts || []).join(', ')}…`, 'busy'); continue; }
if (evt.event === 'clarify') { clarifyEvent = evt; continue; }
if (evt.event === 'final') { finalResult = evt.result; continue; }
if (evt.event === 'error') { errorEvent = evt; continue; }
}
}
if (done) break;
}
els.runButton.disabled = false;
if (errorEvent) {
setStatus(`${errorEvent.code}: ${errorEvent.message}`, 'error');
return;
}
if (clarifyEvent) {
setStatus('Need clarification before drafting.', 'busy');
showClarify(clarifyEvent.questions);
return;
}
if (!finalResult) {
setStatus('Stream ended without a draft.', 'error');
return;
}
setStatus(`Done in ${Math.round((finalResult.latency_ms || 0) / 1000)} s · ${(finalResult.cited_law || []).length} cited source(s)`, 'ok');
lastFinal = finalResult;
renderFinal(finalResult);
pendingClarifications = {}; // reset for next run
}
// ── Pass 3: refine with jurisdiction-scoped formal citations ────────────────
async function runRefine(jurisdiction) {
if (!lastFinal || !lastClassify) {
setStatus('No draft to refine. Run a draft first.', 'error');
return;
}
const refineBtn = document.getElementById('korrRefineBtn');
if (refineBtn) { refineBtn.disabled = true; refineBtn.textContent = 'Refining…'; }
setStatus(`Refining draft with ${jurisdiction} authorities…`, 'busy');
const payload = {
jurisdiction,
language: lang,
original_draft_no: lastFinal.draft_no || '',
classify: lastClassify,
intake: {
recipient_body: lastFinal.recipient_body,
output_type: lastFinal.output_type,
tone: lastFinal.tone,
goal: lastFinal.goal,
},
};
let response;
try {
response = await fetch('api/korrespond-refine.php', {
method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
} catch (err) {
setStatus(`Network error: ${err.message || err}`, 'error');
if (refineBtn) { refineBtn.disabled = false; refineBtn.textContent = 'Refine citations'; }
return;
}
if (!response.ok || !response.body) {
if (response.status === 402 || response.status === 429) {
const d = await response.json().catch(() => ({}));
if (typeof window.dbnFreeTierError === 'function') window.dbnFreeTierError(response.status, d);
} else {
setStatus(`Refine failed (${response.status}).`, 'error');
}
if (refineBtn) { refineBtn.disabled = false; refineBtn.textContent = 'Refine citations'; }
return;
}
const creditsRemaining = response.headers.get('X-Credits-Remaining');
if (creditsRemaining !== null && typeof window.dbnUpdateCredits === 'function') {
window.dbnUpdateCredits(parseInt(creditsRemaining, 10));
}
const reader = response.body.getReader();
const decoder = new TextDecoder('utf-8');
let buffer = '';
let finalResult = null;
let errorEvent = null;
while (true) {
let chunk;
try { chunk = await reader.read(); }
catch (err) {
setStatus(`Stream error: ${err.message || err}`, 'error');
if (refineBtn) { refineBtn.disabled = false; refineBtn.textContent = 'Refine citations'; }
return;
}
const { done, value } = chunk;
if (value) {
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop();
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
let evt; try { evt = JSON.parse(trimmed); } catch (_) { continue; }
if (!evt || !evt.event) continue;
if (evt.event === 'progress') { setStatus(evt.detail || 'Refining…', 'busy'); continue; }
if (evt.event === 'start') { setStatus(`Refining (${evt.jurisdiction})…`, 'busy'); continue; }
if (evt.event === 'retrieval') { setStatus(`Hentet ${evt.sources_count} rettskilder for ${evt.jurisdiction}…`, 'busy'); continue; }
if (evt.event === 'final') { finalResult = evt.result; continue; }
if (evt.event === 'error') { errorEvent = evt; continue; }
}
}
if (done) break;
}
if (refineBtn) { refineBtn.disabled = false; refineBtn.textContent = 'Refine citations'; }
if (errorEvent) {
setStatus(`${errorEvent.code}: ${errorEvent.message}`, 'error');
return;
}
if (!finalResult) {
setStatus('Refine stream ended without a result.', 'error');
return;
}
setStatus(`Refined in ${Math.round((finalResult.latency_ms || 0) / 1000)} s · ${(finalResult.cited_law || []).length} cited authority(ies) · ${finalResult.jurisdiction}`, 'ok');
renderRefined(finalResult);
}
// ── Rendering ───────────────────────────────────────────────────────────────
function renderClassifySummary(c) {
if (!c || !c.summary) return;
let block = els.results.querySelector('#korrClassifyBlock');
if (!block) {
block = document.createElement('div');
block.id = 'korrClassifyBlock';
block.className = 'dr-result-block';
els.results.innerHTML = '';
els.results.appendChild(block);
}
block.innerHTML = `
<h3 style="margin:0 0 10px;font-size:0.95rem;color:var(--ink)">Sammendrag (Pass 1)</h3>
<p>${esc(c.summary)}</p>
${c.applicable_acts && c.applicable_acts.length ? `<p class="upload-hint"><strong>Antatt rettslig grunnlag:</strong> ${c.applicable_acts.map(esc).join(', ')}</p>` : ''}
${c.deadlines && c.deadlines.length ? `<p class="upload-hint"><strong>Frister:</strong> ${c.deadlines.map(esc).join(', ')}</p>` : ''}
`;
}
function renderFinal(data) {
const userLang = data.draft_user_lang || 'en';
const userLangLabel = LANG_LABELS[userLang] || userLang.toUpperCase();
const flags = data.self_check || {};
const cited = data.cited_law || [];
const flagBadge = (key, label) => {
const v = flags[key] || 'ok';
const cls = v === 'ok' ? 'is-ok' : (v === 'warn' ? 'is-warn' : 'is-error');
const icon = v === 'ok' ? '✓' : '!';
return `<span class="korr-flag ${cls}">${icon} ${esc(label)}</span>`;
};
const draftNo = data.draft_no || '';
const draftUser = data.draft_user || '';
const isSameLang = userLang === 'no';
els.results.innerHTML = `
<div class="korr-result-head">
<span class="tool-badge">${esc(data.recipient_body || '')} · ${esc(data.output_type || '')} · ${esc(data.tone || '')}</span>
<div class="korr-flags">
${flagBadge('citations_verified', 'Citations verified')}
${flagBadge('deadline_mentioned', 'Deadline')}
${flagBadge('goal_addressed', 'Goal addressed')}
${flagBadge('tone', 'Tone')}
</div>
</div>
<div class="korr-drafts ${isSameLang ? 'is-single' : ''}">
<div class="korr-draft-col">
<div class="korr-draft-head">
<h3>Norsk (bokmål) — kanonisk</h3>
<div class="korr-draft-actions">
<button type="button" class="secondary-button" data-copy="no">Copy</button>
<button type="button" class="secondary-button" data-download="no">Download .txt</button>
</div>
</div>
<pre class="korr-draft-body" id="korrDraftNo">${esc(draftNo)}</pre>
</div>
${isSameLang ? '' : `
<div class="korr-draft-col">
<div class="korr-draft-head">
<h3>${esc(userLangLabel)} — reference</h3>
<div class="korr-draft-actions">
<button type="button" class="secondary-button" data-copy="user">Copy</button>
<button type="button" class="secondary-button" data-download="user">Download .txt</button>
</div>
</div>
<pre class="korr-draft-body" id="korrDraftUser">${esc(draftUser)}</pre>
</div>`}
</div>
${cited.length ? `
<details class="korr-cited" open>
<summary><strong>Cited law (${cited.length})</strong> — each reference traces to a real corpus passage</summary>
<div class="korr-cited-list">
${cited.map((s) => `
<div class="korr-cited-item">
<div class="korr-cited-head"><strong>[${s.n}] ${esc(s.title)}</strong>${s.section ? ' — ' + esc(s.section) : ''}</div>
<p class="korr-cited-excerpt">${esc(s.excerpt || '')}</p>
${s.source_url ? `<a href="${esc(s.source_url)}" target="_blank" rel="noopener">View source</a>` : ''}
</div>
`).join('')}
</div>
</details>
` : '<p class="upload-hint"><em>No cited law sources — draft is plain-language (no § references available from corpus).</em></p>'}
${data.disclaimer ? `<p class="upload-hint" style="margin-top:16px;font-style:italic">${esc(data.disclaimer)}</p>` : ''}
<section class="korr-refine-panel" id="korrRefinePanel" aria-labelledby="korrRefineTitle">
<h3 id="korrRefineTitle">Refine with formal citations <small>(+1 credit)</small></h3>
<p class="upload-hint">Optional 2nd pass: pull fresh authorities, rewrite citations in formal style ("jf. forvaltningsloven § 17", "jf. Strand Lobben m.fl. mot Norge, EMD-37283/13, §§ 207214"), and append a <em>Rettskilder</em> block at the bottom.</p>
<div class="korr-refine-controls" role="radiogroup" aria-label="Jurisdiction">
<label><input type="radio" name="korrJurisdiction" value="norwegian" checked> Norwegian law only</label>
<label><input type="radio" name="korrJurisdiction" value="echr"> ECHR (EMK + HUDOC)</label>
<label><input type="radio" name="korrJurisdiction" value="both"> Both</label>
<button type="button" id="korrRefineBtn" class="primary-button">Refine citations</button>
</div>
<div id="korrRefinedSlot"></div>
</section>
`;
// Wire copy/download
els.results.querySelectorAll('[data-copy]').forEach((btn) => {
btn.addEventListener('click', () => {
const target = btn.dataset.copy === 'no' ? draftNo : draftUser;
navigator.clipboard?.writeText(target).then(
() => { btn.textContent = 'Copied ✓'; setTimeout(() => btn.textContent = 'Copy', 1500); },
() => { btn.textContent = 'Failed'; }
);
});
});
els.results.querySelectorAll('[data-download]').forEach((btn) => {
btn.addEventListener('click', () => {
const target = btn.dataset.download === 'no' ? draftNo : draftUser;
const suffix = btn.dataset.download === 'no' ? 'no' : userLang;
downloadText(`korrespond-${data.recipient_body}-${suffix}.txt`, target);
});
});
// Wire refine
const refineBtn = document.getElementById('korrRefineBtn');
refineBtn?.addEventListener('click', () => {
const choice = document.querySelector('input[name="korrJurisdiction"]:checked');
runRefine(choice ? choice.value : 'norwegian');
});
}
function renderRefined(data) {
const slot = document.getElementById('korrRefinedSlot');
if (!slot) return;
const userLang = data.draft_user_lang || 'en';
const userLangLabel = LANG_LABELS[userLang] || userLang.toUpperCase();
const flags = data.self_check || {};
const cited = data.cited_law || [];
const isSameLang = userLang === 'no';
const draftNo = data.draft_no || '';
const draftUser = data.draft_user || '';
const jurLabel = data.jurisdiction === 'echr' ? 'ECHR (EMK + HUDOC)'
: data.jurisdiction === 'both' ? 'Norwegian + ECHR'
: 'Norwegian law';
const flagBadge = (key, label) => {
const v = flags[key] || 'ok';
const cls = v === 'ok' ? 'is-ok' : (v === 'warn' ? 'is-warn' : 'is-error');
const icon = v === 'ok' ? '✓' : '!';
return `<span class="korr-flag ${cls}">${icon} ${esc(label)}</span>`;
};
slot.innerHTML = `
<div class="korr-refined">
<div class="korr-result-head">
<span class="tool-badge">Refined · ${esc(jurLabel)}</span>
<div class="korr-flags">
${flagBadge('citations_verified', 'Citations verified')}
${flagBadge('deadline_mentioned', 'Deadline')}
${flagBadge('goal_addressed', 'Goal addressed')}
</div>
</div>
<div class="korr-drafts ${isSameLang ? 'is-single' : ''}">
<div class="korr-draft-col">
<div class="korr-draft-head">
<h3>Norsk (bokmål) — refined</h3>
<div class="korr-draft-actions">
<button type="button" class="secondary-button" data-rcopy="no">Copy</button>
<button type="button" class="secondary-button" data-rdownload="no">Download .txt</button>
</div>
</div>
<pre class="korr-draft-body">${esc(draftNo)}</pre>
</div>
${isSameLang ? '' : `
<div class="korr-draft-col">
<div class="korr-draft-head">
<h3>${esc(userLangLabel)} — refined</h3>
<div class="korr-draft-actions">
<button type="button" class="secondary-button" data-rcopy="user">Copy</button>
<button type="button" class="secondary-button" data-rdownload="user">Download .txt</button>
</div>
</div>
<pre class="korr-draft-body">${esc(draftUser)}</pre>
</div>`}
</div>
${cited.length ? `
<details class="korr-cited" open>
<summary><strong>Cited authorities (${cited.length})</strong> — ${esc(jurLabel)}</summary>
<div class="korr-cited-list">
${cited.map((s) => `
<div class="korr-cited-item">
<div class="korr-cited-head"><strong>[${s.n}] ${esc(s.title)}</strong>${s.section ? ' — ' + esc(s.section) : ''}${s.authority_type ? ' <small>(' + esc(s.authority_type) + ')</small>' : ''}</div>
<p class="korr-cited-excerpt">${esc(s.excerpt || '')}</p>
${s.source_url ? `<a href="${esc(s.source_url)}" target="_blank" rel="noopener">View source</a>` : ''}
</div>
`).join('')}
</div>
</details>` : ''}
</div>
`;
slot.querySelectorAll('[data-rcopy]').forEach((btn) => {
btn.addEventListener('click', () => {
const target = btn.dataset.rcopy === 'no' ? draftNo : draftUser;
navigator.clipboard?.writeText(target).then(
() => { btn.textContent = 'Copied ✓'; setTimeout(() => btn.textContent = 'Copy', 1500); },
() => { btn.textContent = 'Failed'; }
);
});
});
slot.querySelectorAll('[data-rdownload]').forEach((btn) => {
btn.addEventListener('click', () => {
const target = btn.dataset.rdownload === 'no' ? draftNo : draftUser;
const suffix = btn.dataset.rdownload === 'no' ? 'no' : userLang;
downloadText(`korrespond-refined-${data.recipient_body}-${data.jurisdiction}-${suffix}.txt`, target);
});
});
slot.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
// ── utils ───────────────────────────────────────────────────────────────────
function setStatus(message, kind) {
if (!els.status) return;
els.status.textContent = message || '';
els.status.dataset.kind = kind || '';
}
function esc(s) {
return String(s == null ? '' : s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
function downloadText(filename, text) {
const blob = new Blob([text], { type: 'text/plain;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = filename;
document.body.appendChild(a); a.click(); a.remove();
URL.revokeObjectURL(url);
}
})();