/* advocate.js — page-scoped UI for /advocate.php */ (function () { 'use strict'; const els = {}; let lang = window.DBN_TOOLS_LANG || localStorage.getItem('dbn-ui-lang') || 'en'; let uploadFiles = []; let lastResult = null; let branchContext = null; const SLICE_DEFS = [ { id: 'family_core', label: 'Family Law Core' }, { id: 'child_welfare', label: 'Child Welfare' }, { id: 'echr', label: 'ECHR' }, { id: 'hague', label: 'Hague Convention' }, { id: 'norwegian_courts', label: 'Norwegian Courts' }, { id: 'bufdir_guidance', label: 'Bufdir Guidance' }, { id: 'broader_legal', label: 'Broader Legal Support' }, { id: 'dbn_resources', label: 'DBN Resources' }, ]; const STEP_LABELS = [ 'Query interpretation', 'Query expansion', 'Slice resolution', 'Upload indexing', 'Retrieval', 'Synthesis', 'Citation confidence', ]; document.addEventListener('DOMContentLoaded', () => { if (!document.body.dataset.activeTool || document.body.dataset.activeTool !== 'advocate') return; Object.assign(els, { form: document.getElementById('advocateForm'), input: document.getElementById('advInput'), status: document.getElementById('advStatus'), runButton: document.getElementById('advRunButton'), results: document.getElementById('advResults'), traceList: document.getElementById('traceList'), roleSelect: document.getElementById('advRoleSelect'), roleCustom: document.getElementById('advRoleCustom'), slices: Array.from(document.querySelectorAll('.adv-slice')), langButtons: Array.from(document.querySelectorAll('#advLangSwitcher .lang-btn')), engineRadios: Array.from(document.querySelectorAll('input[name="advEngine"]')), subQ: document.getElementById('advSubQ'), subQVal: document.getElementById('advSubQValue'), chunkLimit: document.getElementById('advChunkLimit'), chunkLimitVal: document.getElementById('advChunkLimitValue'), sim: document.getElementById('advSim'), simVal: document.getElementById('advSimValue'), topK: document.getElementById('advTopK'), topKVal: document.getElementById('advTopKValue'), temp: document.getElementById('advTemp'), tempVal: document.getElementById('advTempValue'), uploadZone: document.getElementById('advUploadZone'), uploadInput: document.getElementById('advUploadInput'), uploadPrompt: document.getElementById('advUploadPrompt'), uploadFileInfo: document.getElementById('advUploadFileInfo'), uploadFileList: document.getElementById('advUploadFileList'), uploadClear: document.getElementById('advUploadClear'), modal: document.getElementById('advSourceModal'), modalClose: document.getElementById('advSourceModalClose'), modalTitle: document.getElementById('advSourceModalTitle'), modalEyebrow: document.getElementById('advSourceModalEyebrow'), modalMeta: document.getElementById('advSourceModalMeta'), modalText: document.getElementById('advSourceModalText'), branchPanel: document.getElementById('advBranchPanel'), branchClear: document.getElementById('advBranchClear'), branchOrigin: document.getElementById('advBranchOrigin'), branchSummary: document.getElementById('advBranchSummary'), branchNotes: document.getElementById('advBranchNotes'), }); if (!els.form) return; bindRole(); bindSlices(); bindLang(); bindRanges(); bindUpload(); bindModal(); bindBranch(); els.form.addEventListener('submit', onSubmit); els.results.addEventListener('click', (e) => { const btn = e.target.closest('.dr-branch-btn'); if (btn) branchFromSubQ(btn.dataset.question || ''); }); renderTrace(STEP_LABELS.map((label) => ({ label, detail: 'Waiting…', status: 'idle' }))); }); function bindRole() { if (!els.roleSelect) return; els.roleSelect.addEventListener('change', () => { const isOther = els.roleSelect.value === '__other__'; els.roleCustom.classList.toggle('is-hidden', !isOther); if (isOther) els.roleCustom.focus(); }); } function getAdvocateRole() { if (!els.roleSelect) return ''; if (els.roleSelect.value === '__other__') { return (els.roleCustom ? els.roleCustom.value.trim() : ''); } return els.roleSelect.value; } function bindSlices() { els.slices.forEach((btn) => { btn.addEventListener('click', () => { const isOn = btn.classList.toggle('is-on'); btn.setAttribute('aria-pressed', isOn ? 'true' : 'false'); const badge = btn.querySelector('.dr-slice__badge'); if (badge) badge.textContent = isOn ? 'on' : 'off'; }); }); } 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 bindRanges() { const pairs = [ [els.subQ, els.subQVal, (v) => v], [els.chunkLimit, els.chunkLimitVal, (v) => v], [els.sim, els.simVal, (v) => Number(v).toFixed(2)], [els.topK, els.topKVal, (v) => v], [els.temp, els.tempVal, (v) => Number(v).toFixed(2)], ]; pairs.forEach(([range, label, fmt]) => { if (!range || !label) return; const sync = () => { label.textContent = fmt(range.value); }; range.addEventListener('input', sync); sync(); }); } function bindUpload() { if (!els.uploadZone) return; const onFiles = (fileList) => { const files = Array.from(fileList || []).slice(0, 5); if (uploadFiles.length + files.length > 5) { setStatus('At most 5 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.`, '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 `
  • ${escapeHtml(f.name)}${kb} KB
  • `; }).join(''); } function bindModal() { els.modalClose?.addEventListener('click', closeModal); els.modal?.addEventListener('click', (e) => { if (e.target === els.modal) closeModal(); }); document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && els.modal && !els.modal.classList.contains('is-hidden')) closeModal(); }); } function closeModal() { els.modal?.classList.add('is-hidden'); } function openModal(source) { if (!source) return; els.modalEyebrow.textContent = source.source_origin === 'upload' ? 'Uploaded file' : 'Corpus source'; els.modalTitle.textContent = source.title || 'Source'; const metaRows = [ ['Number', `[${source.n}]`], source.section ? ['Section', source.section] : null, ['Corpus / package', source.package_or_corpus || '—'], source.authority_type ? ['Authority', source.authority_type] : null, source.jurisdiction ? ['Jurisdiction', source.jurisdiction] : null, source.similarity != null ? ['Similarity', String(source.similarity)] : null, source.reranker_score != null ? ['Rerank score', String(source.reranker_score)] : null, source.matched_sub_questions?.length ? ['Matched sub-Q', source.matched_sub_questions.join(', ')] : null, ].filter(Boolean); els.modalMeta.innerHTML = '
    ' + metaRows.map(([k, v]) => `
    ${escapeHtml(k)}
    ${escapeHtml(String(v))}
    `).join('') + '
    '; const summary = source.summary || ''; const chunkText = source.chunk_text || source.excerpt || ''; const isUpload = source.source_origin === 'upload'; const hasDocId = source.document_id != null; let html = summary ? `
    ${escapeHtml(summary)}
    ` : `
    Summary not yet generated — showing raw chunk below.
    `; if (chunkText) { html += ``; html += ``; } if (!isUpload && hasDocId) { html += ``; html += `
    `; } els.modalText.innerHTML = html; const chunkToggle = els.modalText.querySelector('.dr-modal-chunk-toggle'); const chunkDiv = els.modalText.querySelector('.dr-modal-chunk-text'); chunkToggle?.addEventListener('click', () => { const isHidden = chunkDiv.classList.toggle('is-hidden'); chunkToggle.textContent = isHidden ? 'Show matching chunk ▼' : 'Hide matching chunk ▲'; }); const allChunksBtn = els.modalText.querySelector('.dr-modal-all-chunks'); const chunksListDiv = els.modalText.querySelector('.dr-modal-chunks-list'); if (allChunksBtn && chunksListDiv) { allChunksBtn.addEventListener('click', async () => { allChunksBtn.disabled = true; allChunksBtn.textContent = 'Loading…'; try { const res = await fetch(`api/document-chunks.php?document_id=${source.document_id}`, { credentials: 'same-origin' }); const data = await res.json(); if (data.ok && data.chunks) { chunksListDiv.innerHTML = `
    ${escapeHtml(data.document?.title || '')} · ${data.chunks.length} chunks
    ` + data.chunks.map((c) => `
    #${c.chunk_index + 1}${c.section_title ? ' · ' + escapeHtml(c.section_title) : ''}

    ${escapeHtml(truncate(c.content, 300))}

    `).join(''); allChunksBtn.remove(); } else { allChunksBtn.textContent = 'Could not load chunks.'; allChunksBtn.disabled = false; } } catch (_) { allChunksBtn.textContent = 'Error loading chunks.'; allChunksBtn.disabled = false; } }); } els.modal.classList.remove('is-hidden'); } function getSelectedSlices() { const out = {}; SLICE_DEFS.forEach((s) => { const btn = els.slices.find((b) => b.dataset.slice === s.id); out[s.id] = !!(btn && btn.classList.contains('is-on')); }); return out; } function getEngine() { const checked = els.engineRadios.find((r) => r.checked); return checked ? checked.value : 'azure_mini'; } function getControls() { return { sub_q_count: parseInt(els.subQ.value, 10), chunk_limit: parseInt(els.chunkLimit.value, 10), similarity_threshold: parseFloat(els.sim.value), reranker_top_k: parseInt(els.topK.value, 10), temperature: parseFloat(els.temp.value), }; } async function onSubmit(e) { e.preventDefault(); const advocateRole = getAdvocateRole(); if (!advocateRole) { setStatus('Select who you are representing before running.', 'error'); return; } const query = (els.input.value || '').trim(); if (!query && uploadFiles.length === 0) { setStatus('Describe the case situation or upload a file before running.', 'error'); return; } const slices = getSelectedSlices(); if (!Object.values(slices).some(Boolean)) { setStatus('Enable at least one corpus slice.', 'error'); return; } const engine = getEngine(); const expectedDuration = engine === 'azure_full' ? '60–180 seconds with Azure gpt-4o' : (engine === 'gpu' ? '30–90 seconds on GPU' : '15–45 seconds with Azure gpt-4o-mini'); setStatus(`Building advocate brief for ${advocateRole}… (${expectedDuration})`, 'busy'); els.runButton.disabled = true; els.results.innerHTML = `

    Researching your case…

    The agent is generating adversarial sub-questions and retrieving from the legal corpus on behalf of ${escapeHtml(advocateRole)}. Live progress in the right-hand panel. Expect ${expectedDuration}.

    `; const stepState = STEP_LABELS.map((label) => ({ label, detail: 'Queued', status: 'idle' })); renderTrace(stepState); const payload = { query, paste_text: '', slices, engine, language: lang, controls: getControls(), advocate_role: advocateRole, }; if (branchContext) { payload.prior_context = branchContext; payload.branch_notes = (els.branchNotes ? els.branchNotes.value : '').trim(); } const stepKeyToIndex = { interpretation: 0, expansion: 1, slice_resolution: 2, upload_indexing: 3, retrieval: 4, synthesis: 5, confidence: 6, }; let response; try { if (uploadFiles.length > 0) { const form = new FormData(); form.append('payload', JSON.stringify(payload)); uploadFiles.forEach((f) => form.append('files[]', f)); response = await fetch('api/deep-research.php', { method: 'POST', body: form, credentials: 'same-origin' }); } else { response = await fetch('api/deep-research.php', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), credentials: 'same-origin', }); } } catch (err) { setStatus(`Network error: ${err.message || err}`, 'error'); els.runButton.disabled = false; stepState[0] = { ...stepState[0], status: 'error', detail: String(err) }; renderTrace(stepState); return; } if (!response.ok || !response.body) { setStatus(`Request failed (${response.status}).`, 'error'); els.runButton.disabled = false; return; } 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'); 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; } handleStreamEvent(evt); } } if (done) break; } if (errorEvent) { setStatus(`${errorEvent.code}: ${errorEvent.message}`, 'error'); els.runButton.disabled = false; const runningIdx = stepState.findIndex((s) => s.status === 'running'); if (runningIdx >= 0) { stepState[runningIdx] = { ...stepState[runningIdx], status: 'error', detail: errorEvent.message }; renderTrace(stepState); } return; } if (!finalResult) { setStatus('Stream ended without a final result.', 'error'); els.runButton.disabled = false; return; } finalResult.query = query; lastResult = finalResult; const meta = finalResult.trace_metadata || {}; const rc = meta.retrieval_counts || {}; const countSummary = (rc.post_filter_corpus != null) ? `${rc.post_filter_corpus} corpus${rc.filtered_website ? ` (${rc.filtered_website} website filtered)` : ''}${rc.raw_upload ? ` + ${rc.raw_upload} upload` : ''}` : `${meta.source_count || 0} sources`; setStatus( `Done in ${Math.round((finalResult.latency_ms || 0) / 1000)} s · ${countSummary} · confidence ${meta.citation_confidence || '?'}`, 'ok' ); els.runButton.disabled = false; renderTrace(finalResult.trace || []); renderResults(finalResult); function handleStreamEvent(evt) { if (!evt || !evt.event) return; if (evt.event === 'progress') { const d = evt.detail || ''; if (d) setStatus(d, 'busy'); return; } if (evt.event === 'start') { setStatus(`Running… engine=${evt.engine}, uploads=${evt.upload_count || 0}`, 'busy'); return; } if (evt.event === 'step') { const idx = stepKeyToIndex[evt.step]; if (idx === undefined) return; stepState[idx] = { label: evt.label || stepState[idx].label, detail: evt.detail || stepState[idx].detail, status: evt.status || stepState[idx].status, }; renderTrace(stepState); return; } if (evt.event === 'subq') { setStatus(`Retrieving sub-question ${evt.index}/${evt.total}: ${evt.question.slice(0, 80)}${evt.question.length > 80 ? '…' : ''}`, 'busy'); return; } if (evt.event === 'final') { finalResult = evt.result; return; } if (evt.event === 'error') { errorEvent = evt; return; } } } function setStatus(message, kind) { els.status.textContent = message; els.status.style.color = kind === 'error' ? '#b41e1e' : (kind === 'ok' ? 'var(--teal-dark)' : 'var(--muted)'); } function renderTrace(steps) { if (!els.traceList) return; els.traceList.classList.add('is-rich'); els.traceList.innerHTML = steps.map((step, i) => { const statusClass = step.status === 'running' ? 'is-running' : step.status === 'complete' ? 'is-done' : step.status === 'warning' ? 'is-warning' : step.status === 'error' ? 'is-error' : ''; const marker = step.status === 'complete' ? '✓' : step.status === 'warning' ? '!' : step.status === 'error' ? '×' : (i + 1); return `
  • ${marker}
    ${escapeHtml(step.label || '')} ${escapeHtml(step.detail || '')}
  • `; }).join(''); } function renderResults(data) { const sources = data.sources || []; const subs = data.sub_questions || []; const role = data.advocate_role || ''; const strengths = Array.isArray(data.client_strengths) ? data.client_strengths : []; const weaknesses = Array.isArray(data.opposing_weaknesses) ? data.opposing_weaknesses : []; // 1. Advocate banner const bannerHtml = role ? `
    Representing ${escapeHtml(role)} Brief argues for this party · grounded in Norwegian law and ECHR authorities
    ` : ''; // 2. Client strengths const strengthsHtml = strengths.length ? `

    Your strongest arguments

    ` : ''; // 3. Brief const briefHtml = renderBrief(data.brief_markdown || '', sources); // 4. Opposing weaknesses const weaknessesHtml = weaknesses.length ? `

    Gaps in the opposing position

    ` : ''; // 5. Sub-Q report cards const subQReportsHtml = subs.length ? `

    What each sub-question agent researched

    ${subs.length} sub-question${subs.length === 1 ? '' : 's'} framed for ${escapeHtml(role || 'your client')}
    ${subs.map((sq, i) => renderSubQReport(sq, i)).join('')}
    ` : ''; // 6. Sources const sourcesHtml = `

    All sources (${sources.length})

    Click a card to see the full chunk · external link opens the original article
    ${sources.map((s) => renderSourceCard(s)).join('')}
    `; // 7. Uncertainty const uncertHtml = (data.what_remains_uncertain || []).length ? `

    What remains uncertain

    ` : ''; // 8. Next step const nextHtml = data.next_practical_step ? `

    Next practical step

    ${escapeHtml(data.next_practical_step)}

    ` : ''; els.results.innerHTML = ` ${bannerHtml} ${strengthsHtml}

    Advocate brief

    ${briefHtml}
    ${weaknessesHtml} ${subQReportsHtml} ${sourcesHtml} ${uncertHtml} ${nextHtml} `; // Bind source-card clicks els.results.querySelectorAll('.dr-source-card[data-source-n]').forEach((node) => { node.addEventListener('click', (e) => { if (e.target.closest('a')) return; const n = parseInt(node.dataset.sourceN, 10); const src = sources.find((s) => s.n === n); if (src) { openModal(src); flashSource(n); } }); }); // Bind inline citation markers els.results.querySelectorAll('.dr-cite[data-source-n]').forEach((node) => { node.addEventListener('click', (e) => { if (e.target.closest('a')) return; flashSource(parseInt(node.dataset.sourceN, 10)); }); }); } // Convert [n] markers already present in escaped HTML into clickable spans function renderInlineCitations(escapedHtml, sources) { const sourceSet = new Set((sources || []).map((s) => s.n)); return escapedHtml.replace(/\[(\d+(?:\s*[-,]\s*\d+)*)\]/g, (_, group) => { const nums = expandCiteGroup(group); return nums.map((n) => `${n}`).join(''); }); } function renderSubQReport(sq, idx) { const top = sq.top_sources || []; const sourceItems = top.length ? top.map((s) => { const link = s.deep_link || s.source_url; const titleHtml = link ? `${escapeHtml(s.title || 'Untitled')} ` : `${escapeHtml(s.title || 'Untitled')}`; const meta = []; if (s.section) meta.push(escapeHtml(s.section)); if (s.authority_label) meta.push(escapeHtml(s.authority_label)); if (s.source_origin === 'upload') meta.push('your upload'); return `
  • [${s.n ?? '?'}]
    ${titleHtml} ${meta.length ? `
    ${meta.join(' · ')}
    ` : ''}
    ${escapeHtml(truncate(s.excerpt || '', 180))}
  • `; }).join('') : `
  • No sources retrieved for this sub-question.
  • `; return `
    ${escapeHtml(sq.id || ('q' + (idx + 1)))}
    ${escapeHtml(sq.question || '')}
    ${sq.rationale ? `
    ${escapeHtml(sq.rationale)}
    ` : ''}
    `; } function flashSource(n) { document.querySelectorAll('.dr-source-card.is-highlight').forEach((c) => c.classList.remove('is-highlight')); const target = document.querySelector(`.dr-source-card[data-source-n="${n}"]`); if (target) { target.classList.add('is-highlight'); target.scrollIntoView({ behavior: 'smooth', block: 'center' }); setTimeout(() => target.classList.remove('is-highlight'), 1800); } } function bindBranch() { if (!els.branchClear) return; els.branchClear.addEventListener('click', clearBranch); } function clearBranch() { branchContext = null; if (els.branchPanel) els.branchPanel.classList.add('is-hidden'); if (els.branchNotes) els.branchNotes.value = ''; } function branchFromSubQ(question) { if (!lastResult || !question) return; branchContext = { original_query: lastResult.query || '', brief_summary: (lastResult.brief_markdown || '').slice(0, 600), what_we_found: lastResult.what_we_found || '', top_sources: (lastResult.sources || []).slice(0, 5).map((s) => ({ n: s.n, title: s.title, excerpt: (s.excerpt || '').slice(0, 200), })), }; els.input.value = question; if (els.branchOrigin) els.branchOrigin.textContent = 'Original query: ' + branchContext.original_query; if (els.branchSummary) els.branchSummary.textContent = branchContext.brief_summary; if (els.branchPanel) els.branchPanel.classList.remove('is-hidden'); els.form.scrollIntoView({ behavior: 'smooth', block: 'start' }); } function renderSourceCard(s) { const score = s.reranker_score != null ? s.reranker_score : s.similarity; const originTagClass = s.source_origin === 'upload' ? 'dr-source-tag dr-source-tag--upload' : 'dr-source-tag'; const originLabel = s.source_origin === 'upload' ? 'upload' : 'corpus'; const link = s.deep_link || s.source_url; const titleHtml = link ? `${escapeHtml(s.title || 'Untitled')} ` : `${escapeHtml(s.title || 'Untitled')}`; return `
    ${s.n}
    ${titleHtml}
    ${s.section ? `
    ${escapeHtml(s.section)}
    ` : ''}
    ${originLabel} ${s.authority_label ? `${escapeHtml(s.authority_label)}` : ''} ${escapeHtml(s.package_or_corpus || '—')} ${(s.matched_sub_questions || []).map((q) => `${escapeHtml(q)}`).join('')}

    ${escapeHtml(truncate(s.excerpt || '', 240))}

    score
    ${score != null ? Number(score).toFixed(2) : '—'}
    ${s.reranker_score != null && s.similarity != null ? `sim
    ${Number(s.similarity).toFixed(2)}
    ` : ''}
    `; } function renderBrief(markdown, sources) { if (!markdown) return '

    No brief was returned.

    '; const sourceSet = new Set((sources || []).map((s) => s.n)); const escaped = escapeHtml(markdown); const withCites = escaped.replace(/\[(\d+(?:\s*[-,]\s*\d+)*)\]/g, (_, group) => { const nums = expandCiteGroup(group); return nums.map((n) => `${n}`).join(''); }); const withBold = withCites .replace(/\*\*([^*]+)\*\*/g, '$1') .replace(/(^|[^*])\*([^*]+)\*(?!\*)/g, '$1$2') .replace(/`([^`]+)`/g, '$1'); const paragraphs = withBold.split(/\n{2,}/).map((p) => { const t = p.trim(); if (!t) return ''; if (/^### /.test(t)) return `

    ${t.replace(/^### /, '')}

    `; return `

    ${t.replace(/\n/g, '
    ')}

    `; }).join(''); return paragraphs; } function expandCiteGroup(group) { const out = []; group.split(',').forEach((part) => { const range = part.trim().match(/^(\d+)\s*-\s*(\d+)$/); if (range) { const a = parseInt(range[1], 10); const b = parseInt(range[2], 10); for (let i = a; i <= b; i++) out.push(i); } else { const n = parseInt(part.trim(), 10); if (!Number.isNaN(n)) out.push(n); } }); return Array.from(new Set(out)); } function escapeHtml(s) { return String(s) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } function truncate(s, n) { if (!s) return ''; if (s.length <= n) return s; return s.slice(0, n - 1) + '…'; } })();