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:
@@ -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)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user