b2e1bf268d
- KorrespondAgent: add resolveDeployment() helper; fix classify/translate to use
Haiku via Bedrock, draft to use Haiku (quick) or Sonnet (thorough) — fixes broken
withDeployment('gpt-4o-mini') calls when DBN_BEDROCK_ENABLED=true
- korrespond.php: add Quick/Thorough engine picker (case_toggle already present)
- korrespond.js: pass engine in request payload
- api/korrespond.php: accept user-selected engine, auto-save to case_tool_results
for paid users after each successful run, update deployment log label
- CaseResults: add korr_status to listForUser SELECT, add updateStatus() method
- result-action.php: add set_status action for correspondence journal
- account.php: show status dropdown (Draft/Sent/Reply received/Resolved) for
korrespond entries in #analyses, wire JS change handler to result-action.php
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
785 lines
48 KiB
JavaScript
785 lines
48 KiB
JavaScript
/* 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' };
|
||
|
||
// All user-facing chrome strings, localized. Drafts themselves stay in NO + working lang.
|
||
const I18N = {
|
||
pick_recipient: { en: 'Pick a recipient body before drafting.', no: 'Velg en mottaker før utkast.', pl: 'Wybierz odbiorcę przed sporządzeniem projektu.', uk: 'Виберіть отримувача перед чернеткою.' },
|
||
initiate_narrative: { en: 'Describe the situation in "What happened" first.', no: 'Beskriv situasjonen i "Hva skjedde" først.', pl: 'Najpierw opisz sytuację w polu "Co się stało".', uk: 'Спочатку опишіть ситуацію у полі "Що сталося".' },
|
||
reply_no_input: { en: 'Reply mode needs the received letter (upload or paste it).', no: 'Svarmodus krever det mottatte brevet (last opp eller lim inn).', pl: 'Tryb odpowiedzi wymaga otrzymanego pisma (prześlij lub wklej).', uk: 'Режим відповіді потребує отриманого листа (завантажте або вставте).' },
|
||
analyzing: { en: 'Analyzing…', no: 'Analyserer…', pl: 'Analiza…', uk: 'Аналізую…' },
|
||
refining: { en: 'Refining…', no: 'Skriver om…', pl: 'Przepisuję…', uk: 'Переписую…' },
|
||
started: { en: 'Started — {body} / {output}', no: 'Startet — {body} / {output}', pl: 'Rozpoczęto — {body} / {output}', uk: 'Розпочато — {body} / {output}' },
|
||
fetched_sources: { en: 'Fetched {n} sources for {acts}…', no: 'Hentet {n} kilder for {acts}…', pl: 'Pobrano {n} źródeł dla {acts}…', uk: 'Завантажено {n} джерел для {acts}…' },
|
||
need_clarify: { en: 'Need clarification before drafting.', no: 'Trenger avklaring før utkast.', pl: 'Wymaga wyjaśnienia przed sporządzeniem projektu.', uk: 'Потрібне уточнення перед чернеткою.' },
|
||
network_error: { en: 'Network error: {msg}', no: 'Nettverksfeil: {msg}', pl: 'Błąd sieci: {msg}', uk: 'Помилка мережі: {msg}' },
|
||
stream_error: { en: 'Stream error: {msg}', no: 'Strømfeil: {msg}', pl: 'Błąd strumienia: {msg}', uk: 'Помилка потоку: {msg}' },
|
||
request_failed: { en: 'Request failed ({status}).', no: 'Forespørselen mislyktes ({status}).', pl: 'Żądanie nie powiodło się ({status}).', uk: 'Запит не вдалося ({status}).' },
|
||
stream_no_draft: { en: 'Stream ended without a draft.', no: 'Strømmen sluttet uten utkast.', pl: 'Strumień zakończył się bez projektu.', uk: 'Потік завершився без чернетки.' },
|
||
done_summary: { en: 'Done in {s} s · {n} cited source(s)', no: 'Ferdig på {s} s · {n} sitert(e) kilde(r)', pl: 'Gotowe w {s} s · {n} cytowanych źródeł', uk: 'Готово за {s} с · {n} цитованих джерел' },
|
||
refining_status: { en: 'Refining draft with {jur} authorities…', no: 'Skriver om utkast med {jur}-kilder…', pl: 'Przepisuję projekt ze źródłami {jur}…', uk: 'Переписую чернетку з джерелами {jur}…' },
|
||
refining_short: { en: 'Refining ({jur})…', no: 'Skriver om ({jur})…', pl: 'Przepisuję ({jur})…', uk: 'Переписую ({jur})…' },
|
||
refined_summary: { en: 'Refined in {s} s · {n} cited authority(ies) · {jur}', no: 'Omskrevet på {s} s · {n} sitert(e) rettskilde(r) · {jur}', pl: 'Przepisano w {s} s · {n} cytowanych autorytetów · {jur}', uk: 'Переписано за {s} с · {n} цитованих джерел · {jur}' },
|
||
refine_failed: { en: 'Refine failed ({status}).', no: 'Omskriving mislyktes ({status}).', pl: 'Przepisanie nie powiodło się ({status}).', uk: 'Переписування не вдалося ({status}).' },
|
||
refine_no_result: { en: 'Refine stream ended without a result.', no: 'Strømmen sluttet uten resultat.', pl: 'Strumień zakończył się bez wyniku.', uk: 'Потік завершився без результату.' },
|
||
refine_fetched: { en: 'Fetched {n} authorities for {jur}…', no: 'Hentet {n} rettskilder for {jur}…', pl: 'Pobrano {n} autorytetów dla {jur}…', uk: 'Завантажено {n} джерел для {jur}…' },
|
||
working: { en: 'Working…', no: 'Arbeider…', pl: 'Pracuję…', uk: 'Працюю…' },
|
||
working_long: { en: 'Pass 1 extracts facts. If anything is missing we will ask. Then Pass 2 runs hard-RAG retrieval, draft, self-check, and translation.', no: 'Pass 1 henter fakta. Hvis noe mangler spør vi. Deretter kjører Pass 2: kilder, utkast, kontroll og oversettelse.', pl: 'Pass 1 wyodrębnia fakty. Jeśli czegoś brak, zapytamy. Następnie Pass 2: źródła, projekt, kontrola, tłumaczenie.', uk: 'Pass 1 витягує факти. Якщо чогось бракує, ми спитаємо. Потім Pass 2: джерела, чернетка, перевірка, переклад.' },
|
||
no_cited: { en: 'No cited law sources — draft is plain-language (no § references available from corpus).', no: 'Ingen siterte lovkilder — utkastet er på vanlig språk (ingen §-henvisninger fra korpus).', pl: 'Brak cytowanych źródeł prawa — projekt w zwykłym języku (brak odniesień § z korpusu).', uk: 'Немає цитованих джерел права — чернетка звичайною мовою (без § посилань з корпусу).' },
|
||
no_draft_yet: { en: 'No draft to refine. Run a draft first.', no: 'Ingen utkast å skrive om. Lag et utkast først.', pl: 'Brak projektu do przepisania. Najpierw stwórz projekt.', uk: 'Немає чернетки для переписування. Спочатку створіть чернетку.' },
|
||
// Section/header chrome
|
||
ready_title: { en: 'Ready', no: 'Klar', pl: 'Gotowe', uk: 'Готовий' },
|
||
ready_desc: { en: 'Pick a recipient body, describe the situation, choose an output type and tone, then run. Drafts always come back in Norwegian bokmål + your working language, side-by-side, with verified law citations.', no: 'Velg en mottaker, beskriv situasjonen, velg utdataformat og tone, og kjør. Utkast leveres alltid på norsk bokmål + arbeidsspråket, side ved side, med verifiserte lovhenvisninger.', pl: 'Wybierz odbiorcę, opisz sytuację, wybierz format i ton, uruchom. Projekty zawsze zwracane są po norwesku (bokmål) + w Twoim języku, obok siebie, ze zweryfikowanymi cytatami prawa.', uk: 'Виберіть отримувача, опишіть ситуацію, виберіть формат і тон, запустіть. Чернетки повертаються норвезькою (bokmål) + вашою мовою, поряд, з перевіреними посиланнями на закон.' },
|
||
summary_pass1: { en: 'Summary (Pass 1)', no: 'Sammendrag (Pass 1)', pl: 'Podsumowanie (Pass 1)', uk: 'Резюме (Pass 1)' },
|
||
assumed_legal: { en: 'Presumed legal basis:', no: 'Antatt rettslig grunnlag:', pl: 'Domniemana podstawa prawna:', uk: 'Передбачувана правова основа:' },
|
||
deadlines_label: { en: 'Deadlines:', no: 'Frister:', pl: 'Terminy:', uk: 'Терміни:' },
|
||
canonical: { en: 'canonical', no: 'kanonisk', pl: 'kanoniczny', uk: 'канонічний' },
|
||
reference: { en: 'reference', no: 'referanse', pl: 'referencja', uk: 'довідка' },
|
||
refined_label: { en: 'refined', no: 'omskrevet', pl: 'przepisany', uk: 'переписаний' },
|
||
copy: { en: 'Copy', no: 'Kopier', pl: 'Kopiuj', uk: 'Копіювати' },
|
||
copied: { en: 'Copied ✓', no: 'Kopiert ✓', pl: 'Skopiowano ✓', uk: 'Скопійовано ✓' },
|
||
download_txt: { en: 'Download .txt', no: 'Last ned .txt', pl: 'Pobierz .txt', uk: 'Завантажити .txt' },
|
||
cited_law_n: { en: 'Cited law ({n})', no: 'Siterte kilder ({n})', pl: 'Cytowane prawo ({n})', uk: 'Цитоване право ({n})' },
|
||
cited_law_hint: { en: 'each reference traces to a real corpus passage', no: 'hver henvisning kan spores til en reell korpuspassasje', pl: 'każde odniesienie pochodzi z prawdziwego fragmentu korpusu', uk: 'кожне посилання простежується до реального уривка корпусу' },
|
||
cited_authorities_n:{ en: 'Cited authorities ({n})', no: 'Siterte rettskilder ({n})', pl: 'Cytowane autorytety ({n})', uk: 'Цитовані джерела ({n})' },
|
||
view_source: { en: 'View source', no: 'Se kilde', pl: 'Zobacz źródło', uk: 'Переглянути джерело' },
|
||
flag_cites: { en: 'Citations verified', no: 'Henvisninger verifisert', pl: 'Cytaty zweryfikowane', uk: 'Цитати перевірено' },
|
||
flag_deadline: { en: 'Deadline', no: 'Frist', pl: 'Termin', uk: 'Термін' },
|
||
flag_goal: { en: 'Goal addressed', no: 'Mål adressert', pl: 'Cel uwzględniony', uk: 'Мета врахована' },
|
||
flag_tone: { en: 'Tone', no: 'Tone', pl: 'Ton', uk: 'Тон' },
|
||
refine_title: { en: 'Refine with formal citations', no: 'Skriv om med formelle henvisninger', pl: 'Przepisz z formalnymi cytatami', uk: 'Переписати з формальними цитатами' },
|
||
one_extra_credit: { en: '(+1 credit)', no: '(+1 kreditt)', pl: '(+1 kredyt)', uk: '(+1 кредит)' },
|
||
refine_hint: { en: 'Optional 2nd pass: pull fresh authorities, rewrite citations in formal style ("jf. forvaltningsloven § 17", "jf. Strand Lobben m.fl. mot Norge, EMD-37283/13, §§ 207–214"), and append a Rettskilder block at the bottom.', no: 'Valgfri 2. omgang: hent ferske rettskilder, skriv om henvisningene formelt ("jf. forvaltningsloven § 17", "jf. Strand Lobben m.fl. mot Norge, EMD-37283/13, §§ 207–214"), og legg ved en Rettskilder-blokk nederst.', pl: 'Opcjonalna 2. tura: pobierz świeże autorytety, przepisz cytaty w stylu formalnym, dołącz blok Rettskilder na końcu.', uk: 'Опційний 2-й прохід: завантажити свіжі джерела, переписати цитати у формальному стилі, додати блок Rettskilder в кінці.' },
|
||
jur_norwegian: { en: 'Norwegian law only', no: 'Kun norsk rett', pl: 'Tylko prawo norweskie', uk: 'Лише норвезьке право' },
|
||
jur_echr: { en: 'ECHR (EMK + HUDOC)', no: 'EMK (EMK + HUDOC)', pl: 'EKPC (EMK + HUDOC)', uk: 'ЄКПЛ (EMK + HUDOC)' },
|
||
jur_both: { en: 'Both', no: 'Begge', pl: 'Oba', uk: 'Обидва' },
|
||
refine_btn: { en: 'Refine citations', no: 'Skriv om henvisninger', pl: 'Przepisz cytaty', uk: 'Переписати цитати' },
|
||
refine_btn_busy: { en: 'Refining…', no: 'Skriver om…', pl: 'Przepisuję…', uk: 'Переписую…' },
|
||
// Clarify chrome
|
||
clarify_title: { en: 'Before we draft, clarify:', no: 'Før vi skriver, avklar:', pl: 'Zanim sporządzimy projekt, wyjaśnij:', uk: 'Перш ніж писати, уточніть:' },
|
||
clarify_hint: { en: 'Answer what you can, then click Continue draft. Or click Draft anyway to proceed with what we have.', no: 'Svar på det du kan, og klikk Fortsett utkast. Eller klikk Skriv likevel for å fortsette med det vi har.', pl: 'Odpowiedz, co możesz, i kliknij Kontynuuj projekt. Albo kliknij Mimo to napisz, aby kontynuować z tym, co mamy.', uk: 'Дайте відповідь, що можете, потім натисніть Продовжити. Або натисніть Все одно написати, щоб продовжити з тим, що є.' },
|
||
clarify_continue: { en: 'Continue draft', no: 'Fortsett utkast', pl: 'Kontynuuj projekt', uk: 'Продовжити' },
|
||
clarify_force: { en: 'Draft anyway', no: 'Skriv likevel', pl: 'Mimo to napisz', uk: 'Все одно написати' },
|
||
optional: { en: '(optional)', no: '(valgfritt)', pl: '(opcjonalnie)', uk: '(необов’язково)' },
|
||
// Bracket label inside drafts column header — value-only (the language NAME stays its own name)
|
||
no_column: { en: 'Norsk (bokmål)', no: 'Norsk (bokmål)', pl: 'Norsk (bokmål)', uk: 'Norsk (bokmål)' },
|
||
};
|
||
|
||
function t(key, vars) {
|
||
const m = I18N[key];
|
||
if (!m) return key;
|
||
let s = m[lang] || m.en || key;
|
||
if (vars) for (const k in vars) s = s.replaceAll('{' + k + '}', String(vars[k]));
|
||
return s;
|
||
}
|
||
|
||
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();
|
||
applyStaticI18n();
|
||
els.form.addEventListener('submit', (e) => { e.preventDefault(); runRequest(false); });
|
||
});
|
||
|
||
// Apply [data-i18n] attributes on static DOM (and re-apply after lang switch).
|
||
function applyStaticI18n() {
|
||
document.querySelectorAll('[data-i18n]').forEach((el) => {
|
||
const key = el.getAttribute('data-i18n');
|
||
if (key) el.textContent = t(key);
|
||
});
|
||
}
|
||
|
||
// ── 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);
|
||
applyStaticI18n();
|
||
});
|
||
});
|
||
}
|
||
|
||
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());
|
||
const korrDocIds = (document.getElementById('docPickerIds')?.value || '').split(',').map(Number).filter(Boolean);
|
||
const payload = {
|
||
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,
|
||
use_my_case: (typeof window.dbnGetUseMyCase === 'function') ? window.dbnGetUseMyCase() : false,
|
||
engine: (document.querySelector('[name="korrEngine"]:checked')?.value ?? 'azure_mini'),
|
||
};
|
||
if (korrDocIds.length) payload.doc_ids = korrDocIds;
|
||
return payload;
|
||
}
|
||
|
||
async function runRequest(forceDraft) {
|
||
const payload = buildPayload(forceDraft);
|
||
if (!payload.recipient_body) {
|
||
setStatus(t('pick_recipient'), 'error');
|
||
return;
|
||
}
|
||
if (payload.mode === 'initiate' && !payload.narrative) {
|
||
setStatus(t('initiate_narrative'), 'error');
|
||
return;
|
||
}
|
||
if (payload.mode === 'reply' && !uploadFiles.length && !payload.narrative) {
|
||
setStatus(t('reply_no_input'), 'error');
|
||
return;
|
||
}
|
||
|
||
setStatus(t('analyzing'), 'busy');
|
||
els.runButton.disabled = true;
|
||
els.results.innerHTML = `<div class="empty-state"><h3>${esc(t('working'))}</h3><p>${esc(t('working_long'))}</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(t('network_error', { msg: 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(t('request_failed', { status: 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(t('stream_error', { msg: 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 || t('working'), 'busy'); continue; }
|
||
if (evt.event === 'start') { setStatus(t('started', { body: evt.body, output: evt.output_type }), 'busy'); continue; }
|
||
if (evt.event === 'classify') { lastClassify = evt.result; renderClassifySummary(evt.result); continue; }
|
||
if (evt.event === 'retrieval'){ setStatus(t('fetched_sources', { n: evt.sources_count, acts: (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(t('need_clarify'), 'busy');
|
||
showClarify(clarifyEvent.questions);
|
||
return;
|
||
}
|
||
if (!finalResult) {
|
||
setStatus(t('stream_no_draft'), 'error');
|
||
return;
|
||
}
|
||
|
||
setStatus(t('done_summary', { s: Math.round((finalResult.latency_ms || 0) / 1000), n: (finalResult.cited_law || []).length }), 'ok');
|
||
if (typeof finalResult.balance === 'number' && typeof window.dbnUpdateCredits === 'function') {
|
||
window.dbnUpdateCredits(finalResult.balance);
|
||
}
|
||
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(t('no_draft_yet'), 'error');
|
||
return;
|
||
}
|
||
const jurLabel = jurisdiction === 'echr' ? t('jur_echr')
|
||
: jurisdiction === 'both' ? t('jur_both')
|
||
: t('jur_norwegian');
|
||
const refineBtn = document.getElementById('korrRefineBtn');
|
||
if (refineBtn) { refineBtn.disabled = true; refineBtn.textContent = t('refine_btn_busy'); }
|
||
setStatus(t('refining_status', { jur: jurLabel }), '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(t('network_error', { msg: err.message || err }), 'error');
|
||
if (refineBtn) { refineBtn.disabled = false; refineBtn.textContent = t('refine_btn'); }
|
||
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(t('refine_failed', { status: response.status }), 'error');
|
||
}
|
||
if (refineBtn) { refineBtn.disabled = false; refineBtn.textContent = t('refine_btn'); }
|
||
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(t('stream_error', { msg: err.message || err }), 'error');
|
||
if (refineBtn) { refineBtn.disabled = false; refineBtn.textContent = t('refine_btn'); }
|
||
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 || t('refining'), 'busy'); continue; }
|
||
if (evt.event === 'start') { setStatus(t('refining_short', { jur: jurLabel }), 'busy'); continue; }
|
||
if (evt.event === 'retrieval') { setStatus(t('refine_fetched', { n: evt.sources_count, jur: jurLabel }), '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 = t('refine_btn'); }
|
||
|
||
if (errorEvent) {
|
||
setStatus(`${errorEvent.code}: ${errorEvent.message}`, 'error');
|
||
return;
|
||
}
|
||
if (!finalResult) {
|
||
setStatus(t('refine_no_result'), 'error');
|
||
return;
|
||
}
|
||
|
||
setStatus(t('refined_summary', { s: Math.round((finalResult.latency_ms || 0) / 1000), n: (finalResult.cited_law || []).length, jur: jurLabel }), 'ok');
|
||
if (typeof finalResult.balance === 'number' && typeof window.dbnUpdateCredits === 'function') {
|
||
window.dbnUpdateCredits(finalResult.balance);
|
||
}
|
||
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)">${esc(t('summary_pass1'))}</h3>
|
||
<p>${esc(c.summary)}</p>
|
||
${c.applicable_acts && c.applicable_acts.length ? `<p class="upload-hint"><strong>${esc(t('assumed_legal'))}</strong> ${c.applicable_acts.map(esc).join(', ')}</p>` : ''}
|
||
${c.deadlines && c.deadlines.length ? `<p class="upload-hint"><strong>${esc(t('deadlines_label'))}</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', t('flag_cites'))}
|
||
${flagBadge('deadline_mentioned', t('flag_deadline'))}
|
||
${flagBadge('goal_addressed', t('flag_goal'))}
|
||
${flagBadge('tone', t('flag_tone'))}
|
||
</div>
|
||
</div>
|
||
|
||
<div class="korr-drafts ${isSameLang ? 'is-single' : ''}">
|
||
<div class="korr-draft-col">
|
||
<div class="korr-draft-head">
|
||
<h3>${esc(t('no_column'))} — ${esc(t('canonical'))}</h3>
|
||
<div class="korr-draft-actions">
|
||
<button type="button" class="secondary-button" data-copy="no">${esc(t('copy'))}</button>
|
||
<button type="button" class="secondary-button" data-download="no">${esc(t('download_txt'))}</button>
|
||
</div>
|
||
</div>
|
||
<pre class="korr-draft-body" id="korrDraftNo">${esc(draftNo)}</pre>
|
||
<button type="button" class="js-save-corpus secondary-button"
|
||
data-tool="korrespond"
|
||
data-content-id="korrDraftNo"
|
||
data-suggested-title="${esc((data.output_type || 'Brev') + ' — ' + (data.recipient_body || ''))}">
|
||
Save to corpus
|
||
</button>
|
||
</div>
|
||
${isSameLang ? '' : `
|
||
<div class="korr-draft-col">
|
||
<div class="korr-draft-head">
|
||
<h3>${esc(userLangLabel)} — ${esc(t('reference'))}</h3>
|
||
<div class="korr-draft-actions">
|
||
<button type="button" class="secondary-button" data-copy="user">${esc(t('copy'))}</button>
|
||
<button type="button" class="secondary-button" data-download="user">${esc(t('download_txt'))}</button>
|
||
</div>
|
||
</div>
|
||
<pre class="korr-draft-body" id="korrDraftUser">${esc(draftUser)}</pre>
|
||
<button type="button" class="js-save-corpus secondary-button"
|
||
data-tool="korrespond"
|
||
data-content-id="korrDraftUser"
|
||
data-suggested-title="${esc((data.output_type || 'Brev') + ' — ' + (data.recipient_body || '') + ' (translation)')}">
|
||
Save to corpus
|
||
</button>
|
||
</div>`}
|
||
</div>
|
||
|
||
${cited.length ? `
|
||
<details class="korr-cited" open>
|
||
<summary><strong>${esc(t('cited_law_n', { n: cited.length }))}</strong> — ${esc(t('cited_law_hint'))}</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">${esc(t('view_source'))}</a>` : ''}
|
||
</div>
|
||
`).join('')}
|
||
</div>
|
||
</details>
|
||
` : `<p class="upload-hint"><em>${esc(t('no_cited'))}</em></p>`}
|
||
|
||
${data.disclaimer ? `<p class="upload-hint" style="margin-top:16px;font-style:italic">${esc(data.disclaimer)}</p>` : ''}
|
||
|
||
${(data.legal_check && data.legal_check.length) ? `
|
||
<div class="korr-legal-check">
|
||
<h4 class="korr-legal-check__title">⚖ Legal threshold check <small>(dbn-legal-agent-v3)</small></h4>
|
||
${data.legal_check.map((f) => `
|
||
<div class="bvj-red-flag">
|
||
<div class="bvj-red-flag__head">
|
||
<div class="bvj-red-flag__desc">${esc(f.description || '')}</div>
|
||
<span class="bvj-severity bvj-severity-${esc(f.severity || 'low')}">${esc(f.severity || 'low')}</span>
|
||
</div>
|
||
${f.legal_basis ? `<span class="bvj-red-flag__legal">${esc(f.legal_basis)}</span>` : ''}
|
||
${f.what_to_check ? `<details class="bvj-red-flag__details"><summary>What to verify</summary><p class="bvj-red-flag__check">${esc(f.what_to_check)}</p></details>` : ''}
|
||
</div>
|
||
`).join('')}
|
||
</div>
|
||
` : ''}
|
||
|
||
<section class="korr-refine-panel" id="korrRefinePanel" aria-labelledby="korrRefineTitle">
|
||
<h3 id="korrRefineTitle">${esc(t('refine_title'))} <small>${esc(t('one_extra_credit'))}</small></h3>
|
||
<p class="upload-hint">${esc(t('refine_hint'))}</p>
|
||
<div class="korr-refine-controls" role="radiogroup" aria-label="Jurisdiction">
|
||
<label><input type="radio" name="korrJurisdiction" value="norwegian" checked> ${esc(t('jur_norwegian'))}</label>
|
||
<label><input type="radio" name="korrJurisdiction" value="echr"> ${esc(t('jur_echr'))}</label>
|
||
<label><input type="radio" name="korrJurisdiction" value="both"> ${esc(t('jur_both'))}</label>
|
||
<button type="button" id="korrRefineBtn" class="primary-button">${esc(t('refine_btn'))}</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 = t('copied'); setTimeout(() => btn.textContent = t('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' ? t('jur_echr')
|
||
: data.jurisdiction === 'both' ? t('jur_both')
|
||
: t('jur_norwegian');
|
||
|
||
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">${esc(t('refined_label'))} · ${esc(jurLabel)}</span>
|
||
<div class="korr-flags">
|
||
${flagBadge('citations_verified', t('flag_cites'))}
|
||
${flagBadge('deadline_mentioned', t('flag_deadline'))}
|
||
${flagBadge('goal_addressed', t('flag_goal'))}
|
||
</div>
|
||
</div>
|
||
|
||
<div class="korr-drafts ${isSameLang ? 'is-single' : ''}">
|
||
<div class="korr-draft-col">
|
||
<div class="korr-draft-head">
|
||
<h3>${esc(t('no_column'))} — ${esc(t('refined_label'))}</h3>
|
||
<div class="korr-draft-actions">
|
||
<button type="button" class="secondary-button" data-rcopy="no">${esc(t('copy'))}</button>
|
||
<button type="button" class="secondary-button" data-rdownload="no">${esc(t('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)} — ${esc(t('refined_label'))}</h3>
|
||
<div class="korr-draft-actions">
|
||
<button type="button" class="secondary-button" data-rcopy="user">${esc(t('copy'))}</button>
|
||
<button type="button" class="secondary-button" data-rdownload="user">${esc(t('download_txt'))}</button>
|
||
</div>
|
||
</div>
|
||
<pre class="korr-draft-body">${esc(draftUser)}</pre>
|
||
</div>`}
|
||
</div>
|
||
|
||
${cited.length ? `
|
||
<details class="korr-cited" open>
|
||
<summary><strong>${esc(t('cited_authorities_n', { n: 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">${esc(t('view_source'))}</a>` : ''}
|
||
</div>
|
||
`).join('')}
|
||
</div>
|
||
</details>` : ''}
|
||
|
||
${(data.legal_check && data.legal_check.length) ? `
|
||
<div class="korr-legal-check">
|
||
<h4 class="korr-legal-check__title">⚖ Legal threshold check <small>(dbn-legal-agent-v3)</small></h4>
|
||
${data.legal_check.map((f) => `
|
||
<div class="bvj-red-flag">
|
||
<div class="bvj-red-flag__head">
|
||
<div class="bvj-red-flag__desc">${esc(f.description || '')}</div>
|
||
<span class="bvj-severity bvj-severity-${esc(f.severity || 'low')}">${esc(f.severity || 'low')}</span>
|
||
</div>
|
||
${f.legal_basis ? `<span class="bvj-red-flag__legal">${esc(f.legal_basis)}</span>` : ''}
|
||
${f.what_to_check ? `<details class="bvj-red-flag__details"><summary>What to verify</summary><p class="bvj-red-flag__check">${esc(f.what_to_check)}</p></details>` : ''}
|
||
</div>
|
||
`).join('')}
|
||
</div>
|
||
` : ''}
|
||
</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 = t('copied'); setTimeout(() => btn.textContent = t('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, '&').replace(/</g, '<').replace(/>/g, '>')
|
||
.replace(/"/g, '"').replace(/'/g, ''');
|
||
}
|
||
|
||
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);
|
||
}
|
||
})();
|