diff --git a/api/redact-download.php b/api/redact-download.php new file mode 100644 index 0000000..0b77db5 --- /dev/null +++ b/api/redact-download.php @@ -0,0 +1,129 @@ + ['message' => 'ZipArchive extension not available.']]); + exit; +} + +$docx = buildMinimalDocx($text); + +header('Content-Type: application/vnd.openxmlformats-officedocument.wordprocessingml.document'); +header('Content-Disposition: attachment; filename="redacted.docx"'); +header('Content-Length: ' . strlen($docx)); +header('Cache-Control: no-store'); +echo $docx; +exit; + +function buildMinimalDocx(string $text): string +{ + $tmp = tempnam(sys_get_temp_dir(), 'dbn_docx_'); + @unlink($tmp); + $tmp .= '.docx'; + + $zip = new ZipArchive(); + $zip->open($tmp, ZipArchive::CREATE | ZipArchive::OVERWRITE); + + $zip->addFromString('[Content_Types].xml', contentTypesXml()); + $zip->addFromString('_rels/.rels', relsXml()); + $zip->addFromString('word/document.xml', documentXml($text)); + $zip->addFromString('word/_rels/document.xml.rels', wordRelsXml()); + $zip->addFromString('docProps/app.xml', appXml()); + $zip->addFromString('docProps/core.xml', coreXml()); + + $zip->close(); + + $bytes = file_get_contents($tmp); + @unlink($tmp); + return $bytes; +} + +function documentXml(string $text): string +{ + $lines = explode("\n", str_replace("\r\n", "\n", str_replace("\r", "\n", $text))); + $paras = []; + foreach ($lines as $line) { + $safe = htmlspecialchars($line, ENT_XML1 | ENT_COMPAT, 'UTF-8'); + if ($safe === '') { + $paras[] = ''; + } else { + $paras[] = '' + . '' . $safe . ''; + } + } + return '' + . '' + . '' . implode('', $paras) + . '' + . '' + . ''; +} + +function contentTypesXml(): string +{ + return '' + . '' + . '' + . '' + . '' + . '' + . '' + . ''; +} + +function relsXml(): string +{ + return '' + . '' + . '' + . '' + . '' + . ''; +} + +function wordRelsXml(): string +{ + return '' + . ''; +} + +function appXml(): string +{ + return '' + . '' + . 'DoBetterNorge Redact' + . ''; +} + +function coreXml(): string +{ + $date = date('Y-m-d\TH:i:s\Z'); + return '' + . '' + . 'DoBetterNorge' + . '' . $date . '' + . ''; +} diff --git a/assets/css/tools.css b/assets/css/tools.css index f8d8ba9..03d1d74 100644 --- a/assets/css/tools.css +++ b/assets/css/tools.css @@ -1470,3 +1470,71 @@ p { accent-color: var(--teal); cursor: pointer; } + +/* ─── Advanced settings panel (Redact tool) ───────────────────────────────── */ + +.advanced-panel { + border-top: 1px solid var(--line); + margin-top: 0.6rem; +} + +.advanced-toggle { + display: flex; + align-items: center; + gap: 0.4rem; + padding: 0.55rem 0; + font-size: 0.82rem; + font-weight: 500; + color: var(--teal); + cursor: pointer; + user-select: none; + list-style: none; +} + +.advanced-toggle::-webkit-details-marker { display: none; } + +.advanced-toggle::before { + content: '▸'; + font-size: 0.7rem; + transition: transform 0.18s ease; + display: inline-block; +} + +.advanced-panel[open] .advanced-toggle::before { + transform: rotate(90deg); +} + +.advanced-panel[open] .advanced-toggle { + margin-bottom: 0.5rem; +} + +/* ─── Redact download buttons ─────────────────────────────────────────────── */ + +.redact-downloads { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-top: 1rem; +} + +.redact-dl-btn { + padding: 0.38rem 0.9rem; + border-radius: 6px; + font-size: 0.82rem; + font-weight: 500; + background: var(--soft-teal); + color: var(--teal-dark); + border: 1px solid rgba(15, 118, 110, 0.2); + cursor: pointer; + transition: background 0.15s, border-color 0.15s; +} + +.redact-dl-btn:hover { + background: #d0ede9; + border-color: var(--teal); +} + +.redact-dl-btn:disabled { + opacity: 0.55; + cursor: progress; +} diff --git a/assets/js/tools.js b/assets/js/tools.js index 8494ad4..df67b45 100644 --- a/assets/js/tools.js +++ b/assets/js/tools.js @@ -52,6 +52,11 @@ const REDACT_I18N = { redactRunning: 'Redacting…', redactReadyTitle: 'Ready', redactReadyDesc: 'Paste text or upload a file, configure redaction options, then run.', + redactAdvancedToggle: 'Advanced settings', + redactDownloadTxt: 'Download .txt', + redactDownloadDocx: 'Download .docx', + redactCopy: 'Copy', + redactCopied: 'Copied!', }, no: { redactEngine: 'Motor', @@ -101,6 +106,11 @@ const REDACT_I18N = { redactRunning: 'Redigerer…', redactReadyTitle: 'Klar', redactReadyDesc: 'Lim inn tekst eller last opp en fil, konfigurer redigeringsalternativene, og kjør.', + redactAdvancedToggle: 'Avanserte innstillinger', + redactDownloadTxt: 'Last ned .txt', + redactDownloadDocx: 'Last ned .docx', + redactCopy: 'Kopier', + redactCopied: 'Kopiert!', }, uk: { redactEngine: 'Рушій', @@ -150,6 +160,11 @@ const REDACT_I18N = { redactRunning: 'Редагування…', redactReadyTitle: 'Готово', redactReadyDesc: 'Вставте текст або завантажте файл, налаштуйте параметри, запустіть.', + redactAdvancedToggle: 'Розширені налаштування', + redactDownloadTxt: 'Завантажити .txt', + redactDownloadDocx: 'Завантажити .docx', + redactCopy: 'Копіювати', + redactCopied: 'Скопійовано!', }, pl: { redactEngine: 'Silnik', @@ -199,12 +214,18 @@ const REDACT_I18N = { redactRunning: 'Redagowanie…', redactReadyTitle: 'Gotowe', redactReadyDesc: 'Wklej tekst lub wgraj plik, skonfiguruj opcje redakcji, uruchom.', + redactAdvancedToggle: 'Ustawienia zaawansowane', + redactDownloadTxt: 'Pobierz .txt', + redactDownloadDocx: 'Pobierz .docx', + redactCopy: 'Kopiuj', + redactCopied: 'Skopiowano!', }, }; let lastTimelineEvents = []; let audioQueue = []; // [{file, status: 'pending'|'processing'|'done'|'error', result}] let lastTranscriptData = null; +let lastRedactedText = null; const VOCAB_PRESETS = { barnerett: 'Barnevernet, Fylkesnemnda, barnevernloven, barneloven, barnets beste, samvær, foreldreansvar, omsorgsovertakelse, sakkyndig, advokat, prosessfullmektig, dommer, vitne, tolk, bistandsadvokat, fosterforeldre, fosterhjem, akuttvedtak, statsforvalter, Bufetat, saksbehandler, rettslig medhold, begjæring, samtykke, tilsynsfører', @@ -729,6 +750,9 @@ document.addEventListener('DOMContentLoaded', () => { if (e.target.closest('#dlTxt')) downloadTranscriptTxt(); if (e.target.closest('#dlSrt')) downloadTranscriptSrt(); if (e.target.closest('#dlVtt')) downloadTranscriptVtt(); + if (e.target.closest('#rdlCopy')) copyRedactedText(); + if (e.target.closest('#rdlTxt')) downloadRedactedTxt(); + if (e.target.closest('#rdlDocx')) downloadRedactedDocx(); }); const activeTool = document.body.dataset.activeTool || state.activeTool; setTool(activeTool); @@ -1074,7 +1098,14 @@ function renderMainFinding(data) { return `

${escapeHtml(data.answer || data.what_we_found || '')}

`; } if (data.tool === 'redact') { - return `
${escapeHtml(data.redacted_text || '')}
${renderEntityCounts(data.entity_counts)}`; + lastRedactedText = data.redacted_text || ''; + const t = (k) => currentRedactT(k) || k; + const dlRow = `
+ + + +
`; + return `
${escapeHtml(lastRedactedText)}
${renderEntityCounts(data.entity_counts)}${dlRow}`; } if (data.tool === 'timeline') { lastTimelineEvents = data.events || []; @@ -1459,6 +1490,42 @@ function downloadTranscriptVtt() { downloadBlob(new Blob([lines.join('\n')], { type: 'text/vtt' }), 'transcript.vtt'); } +async function copyRedactedText() { + if (!lastRedactedText) return; + const btn = document.getElementById('rdlCopy'); + await navigator.clipboard.writeText(lastRedactedText); + if (btn) { + const orig = btn.textContent; + btn.textContent = currentRedactT('redactCopied') || 'Copied!'; + setTimeout(() => { btn.textContent = orig; }, 1800); + } +} + +function downloadRedactedTxt() { + if (!lastRedactedText) return; + downloadBlob(new Blob([lastRedactedText], { type: 'text/plain' }), 'redacted.txt'); +} + +async function downloadRedactedDocx() { + if (!lastRedactedText) return; + const btn = document.getElementById('rdlDocx'); + if (btn) btn.disabled = true; + try { + const resp = await fetch('/api/redact-download.php', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ text: lastRedactedText, format: 'docx' }), + }); + if (!resp.ok) throw new Error('Download failed'); + const blob = await resp.blob(); + downloadBlob(blob, 'redacted.docx'); + } catch (e) { + alert(e.message); + } finally { + if (btn) btn.disabled = false; + } +} + function resetAudio() { audioQueue = []; if (!els.audioInput) return; diff --git a/redact.php b/redact.php index 81fdd5c..3f5b6c5 100644 --- a/redact.php +++ b/redact.php @@ -24,61 +24,66 @@ require_once __DIR__ . '/includes/layout.php';

Azure engines use your BNL Azure credits. GPU runs the local LiteLLM proxy. Regex-only is instant and free but finds no names or organisations.

-
- Mode - - -
-

Standard: regex patterns + LLM scan for names/orgs/places. Strict: also replaces any capitalised two-word phrase as a potential name — more aggressive, may produce false positives.

+
+ Advanced settings -
- Region - - - - -
-

Nordic: Norwegian fødselsnummer, phone, email, addresses. European: adds IBAN, SE personnummer, UK NI. ECHR: adds application numbers, DOB phrases. Global: adds US SSN, document numbers.

- -
- Redact - - - - -
- -
- Officials - -
-

When checked, judges, expert witnesses and caseworkers keep their names in a labelled tag: [JUDGE: Andersen]. Uncheck to replace all names with generic role tags.

- -
- Output - - - -
-

Contextual: each person gets a role tag so their identity is traceable within the document. Generic: all names become [PERSON]. Pseudonyms: replaced with plausible fake Norwegian values.

- -
-
- Exempt names - +
+ Mode + +
-
-

Names listed here will never be redacted, even if the AI would otherwise remove them — e.g. a judge or expert who must remain identifiable.

-
+

Standard: regex patterns + LLM scan for names/orgs/places. Strict: also replaces any capitalised two-word phrase as a potential name — more aggressive, may produce false positives.

-
-
- Name aliases - +
+ Region + + + +
-
-

Replace a specific name with a custom bracketed label, e.g. “David Jr” → [Junior].

-
+

Nordic: Norwegian fødselsnummer, phone, email, addresses. European: adds IBAN, SE personnummer, UK NI. ECHR: adds application numbers, DOB phrases. Global: adds US SSN, document numbers.

+ +
+ Redact + + + + +
+ +
+ Officials + +
+

When checked, judges, expert witnesses and caseworkers keep their names in a labelled tag: [JUDGE: Andersen]. Uncheck to replace all names with generic role tags.

+ +
+ Output + + + +
+

Contextual: each person gets a role tag so their identity is traceable within the document. Generic: all names become [PERSON]. Pseudonyms: replaced with plausible fake Norwegian values.

+ +
+
+ Exempt names + +
+
+

Names listed here will never be redacted, even if the AI would otherwise remove them — e.g. a judge or expert who must remain identifiable.

+
+ +
+
+ Name aliases + +
+
+

Replace a specific name with a custom bracketed label, e.g. “David Jr” → [Junior].

+
+ +