Add debug copy button and functionality

Includes a button for administrators to copy all transcripts and the
document markdown to the clipboard.
The data is embedded as JSON in a script tag and pre-sanitized to
prevent
`</script>` tag injection.
The JavaScript handles copying to the clipboard using the modern
Clipboard API,
with a fallback for older browsers.
This commit is contained in:
2026-05-03 12:13:12 +02:00
parent 5ee276ba1d
commit bf6464d7e1
2 changed files with 86 additions and 3 deletions
+34 -3
View File
@@ -346,6 +346,12 @@ struct CasePageTemplate {
/// Session CSRF token — rendered as a hidden field into every /// Session CSRF token — rendered as a hidden field into every
/// state-changing form on the page (close/reopen/analyze/reset/logout). /// state-changing form on the page (close/reopen/analyze/reset/logout).
csrf_token: String, csrf_token: String,
/// Admin-only debug copy bundle, pre-serialised as JSON for inline
/// embedding into a `<script type="application/json">` block. Shape:
/// `{"transcripts":["..."],"document":"..."|null}`. Empty string when
/// `is_admin` is false (template skips the block in that case anyway).
/// Pre-sanitised against `</script>` breakout via `<\/` substitution.
debug_copy_json: String,
} }
#[derive(Template)] #[derive(Template)]
@@ -631,9 +637,12 @@ pub async fn handle_case_page(
// Read document first; if it's on disk we display it regardless of what // Read document first; if it's on disk we display it regardless of what
// the `analyzing` flag would otherwise say. Resolves the narrow race // the `analyzing` flag would otherwise say. Resolves the narrow race
// where the worker finishes between flag check and template render. // where the worker finishes between flag check and template render.
let document_html = read_document(&case_dir) // Keep the raw markdown around for the admin debug-copy bundle so we
.await // don't pay a second filesystem read.
.map(|md| crate::analyze::render::md_to_html(&md)); let document_md = read_document(&case_dir).await;
let document_html = document_md
.as_deref()
.map(crate::analyze::render::md_to_html);
let recordings = scan_recordings(&case_dir).await; let recordings = scan_recordings(&case_dir).await;
let (oneliner, oneliner_is_manual) = compute_oneliner_display(&case_dir, &recordings).await; let (oneliner, oneliner_is_manual) = compute_oneliner_display(&case_dir, &recordings).await;
@@ -658,6 +667,27 @@ pub async fn handle_case_page(
let is_closed = crate::paths::is_closed(&case_dir).await; let is_closed = crate::paths::is_closed(&case_dir).await;
// Admin debug-copy payload — only built when the viewer is an admin
// so non-admin renders skip the JSON serialisation entirely.
let debug_copy_json = if is_admin {
let transcripts: Vec<&str> = recordings
.iter()
.filter_map(|r| r.transcript.as_content())
.collect();
let payload = serde_json::json!({
"transcripts": transcripts,
"document": document_md,
});
// `</` breaks out of a `<script>` element; the inline JSON block
// would otherwise be vulnerable if a transcript or the document
// ever contained a literal `</script>`.
serde_json::to_string(&payload)
.unwrap_or_else(|_| "{}".to_owned())
.replace("</", "<\\/")
} else {
String::new()
};
CasePageTemplate { CasePageTemplate {
case_id: case_id_str, case_id: case_id_str,
case_id_short, case_id_short,
@@ -676,6 +706,7 @@ pub async fn handle_case_page(
is_admin, is_admin,
is_closed, is_closed,
csrf_token: user.csrf_token, csrf_token: user.csrf_token,
debug_copy_json,
} }
.render() .render()
.map(Html) .map(Html)
+52
View File
@@ -400,6 +400,15 @@
<input type="hidden" name="return_to" value="/web/cases/{{ case_id }}"> <input type="hidden" name="return_to" value="/web/cases/{{ case_id }}">
<button type="submit">Reset</button> <button type="submit">Reset</button>
</form> </form>
<button
type="button"
id="debug-copy-btn"
class="admin-only"
title="Alle Transkripte + Dokument-Markdown in die Zwischenablage"
>Debug-Copy</button>
<script type="application/json" id="debug-copy-data">
{{ debug_copy_json|safe }}
</script>
{% endif %} {% endif %}
</div> </div>
@@ -604,5 +613,48 @@
<script> <script>
{% include "partials/oneliner_edit.js" %} {% include "partials/oneliner_edit.js" %}
</script> </script>
{% if is_admin %}
<script>
(() => {
const btn = document.getElementById("debug-copy-btn");
const data = document.getElementById("debug-copy-data");
if (!btn || !data) return;
let payload;
try {
payload = JSON.parse(data.textContent);
} catch (_) {
return;
}
btn.addEventListener("click", async () => {
const parts = [...(payload.transcripts || [])];
if (payload.document) parts.push(payload.document);
const text = parts.join("\n\n---\n\n");
let ok = false;
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(text);
ok = true;
}
} catch (_) {}
if (!ok) {
const ta = document.createElement("textarea");
ta.value = text;
ta.setAttribute("readonly", "");
ta.style.position = "fixed";
ta.style.top = "-1000px";
document.body.appendChild(ta);
ta.select();
try { ok = document.execCommand("copy"); } catch (_) {}
ta.remove();
}
if (ok) {
const original = btn.textContent;
btn.textContent = "Kopiert!";
setTimeout(() => { btn.textContent = original; }, 1500);
}
});
})();
</script>
{% endif %}
</body> </body>
</html> </html>