use super::escape::esc;
use super::fleet::fleet_sections_html;
use super::format::format_duration_secs;
use super::layout::{FAVICON_LINK, PAGE_CSS};
use super::proc::{elapsed_phrase, proc_meta_html};
use super::session::{session_ended_text, session_lede_html};
use super::workflow::{proc_task_attrs, workflow_graph_html};
use crate::daemon::model::{ProcRecord, Session};
use crate::json::quote;
pub(crate) enum CastExport {
Cast { ndjson: String, summary: Option<String>, chapters: Vec<(f64, String)>, diff_html: Option<String> },
Note { text: String, diff_html: Option<String> },
}
impl CastExport {
pub(crate) fn diff_html(&self) -> Option<&str> {
match self {
CastExport::Cast { diff_html, .. } | CastExport::Note { diff_html, .. } => diff_html.as_deref(),
}
}
}
const EXPORT_EXTRA_CSS: &str = r#"
.snapshot-note { color: var(--text-muted); font-size: 0.85rem; margin: -8px 0 16px; }
"#;
pub(crate) fn session_export_page(session: &Session, exports: &[CastExport], now: u64) -> String {
let id = esc(&session.id);
let lifecycle = session.lifecycle_status(now);
let lede = session_lede_html(session, lifecycle);
let when = format!("{} UTC", crate::runtime::format_utc_timestamp(session.started_at));
let ended = session_ended_text(session, lifecycle);
let duration = session.duration_secs(now).map(format_duration_secs).unwrap_or_else(|| "—".into());
let workflow = workflow_graph_html(session, now);
let fleets = fleet_sections_html(session);
let mut sections = String::new();
let mut data_entries: Vec<String> = Vec::new();
for (proc, export) in session.procs.iter().zip(exports) {
sections.push_str(&proc_section(session, proc, export));
if let CastExport::Cast { ndjson, summary, chapters, .. } = export {
let markers: Vec<String> = chapters.iter().map(|(t, title)| format!("[{t}, {}]", quote(title))).collect();
data_entries.push(format!(
"{{ \"proc\": {idx}, \"cast\": {cast}, \"summary\": {summary}, \"markers\": [{markers}] }}",
idx = proc.index,
cast = quote(ndjson),
summary = summary.as_deref().map(quote).unwrap_or_else(|| "null".into()),
markers = markers.join(", "),
));
}
}
let data = format!("[{}]", data_entries.join(",\n")).replace("</", "<\\/");
format!(
r#"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
{favicon}
<title>scsh job {id}</title>
<style>{css}{player_css}{extra_css}</style>
</head>
<body>
<p class="page-lede">{lede}</p>
<div class="card card--accent-left-purple">
<dl class="session-meta">
<dt>Job</dt><dd><code>{id}</code></dd>
<dt>Started</dt><dd>{when}</dd>
<dt>Ended</dt><dd>{ended}</dd>
<dt>Duration</dt><dd>{duration}</dd>
<dt>Repo</dt><dd><code class="repo-path">{repo}</code></dd>
<dt>Branch</dt><dd><code>{branch}</code></dd>
</dl>
</div>
<p class="snapshot-note">Offline snapshot — everything below plays without a network.</p>
{workflow}{fleets}<div class="procs">
{sections}</div>
<script>{player_js}</script>
<script>
const CASTS = {data};
CASTS.forEach((c) => {{
const box = document.querySelector('.cast[data-proc="' + c.proc + '"]');
const mount = box && box.querySelector('.cast-player');
if (!mount) return;
// Chapters (c.markers) are player chrome: the ☰ panel, the seek-bar ticks, [/] keys.
box._player = BeeCastPlayer.create({{ data: c.cast }}, mount, {{
fit: 'both', controls: true, idleTimeLimit: 2, markers: c.markers,
accessibility: 'snapshot',
}});
}});
// Opening a row hands its player the keyboard, exactly like the live page.
document.querySelectorAll('details.proc').forEach((det) => det.addEventListener('toggle', () => {{
if (!det.open) return;
const root = det.querySelector('.beecast-player');
if (root) {{ try {{ root.focus({{ preventScroll: true }}); }} catch (_) {{}} }}
}}));
</script>
</body>
</html>
"#,
favicon = FAVICON_LINK,
css = PAGE_CSS,
player_css = super::PLAYER_CSS,
player_js = super::PLAYER_JS,
extra_css = EXPORT_EXTRA_CSS,
lede = lede,
ended = esc(&ended),
duration = esc(&duration),
workflow = workflow,
fleets = fleets,
branch = esc(&session.branch),
repo = esc(&session.repo),
)
}
fn srcdoc_attr(html: &str) -> String {
html.replace('&', "&").replace('"', """).replace("</", "<\\/")
}
fn diff_embed_html(diff_html: Option<&str>) -> String {
let Some(html) = diff_html.filter(|h| !h.is_empty()) else {
return String::new();
};
format!(
r#"<details class="proc-diff"><summary>⇄ commits diff</summary><iframe sandbox="allow-scripts allow-same-origin" srcdoc="{srcdoc}"></iframe></details>
"#,
srcdoc = srcdoc_attr(html),
)
}
fn diff_chip_html(has_diff: bool) -> String {
if has_diff {
r#"<span class="proc-diff" title="Commits diff embedded below">⇄ commits diff</span>"#.into()
} else {
String::new()
}
}
fn proc_section(session: &Session, proc: &ProcRecord, export: &CastExport) -> String {
let note = proc.detail.as_deref().or(proc.note.as_deref()).unwrap_or("");
let elapsed = elapsed_phrase(proc.status, proc.elapsed, proc.fail_reason.as_deref());
let diff = export.diff_html();
let body = match export {
CastExport::Cast { summary, chapters, .. } => {
let summary_html = match summary.as_deref().filter(|s| !s.is_empty()) {
Some(s) => format!("<div class=\"cast-summary\">{}</div>\n", esc(s)),
None => String::new(),
};
let chapter_keys = if chapters.is_empty() { "" } else { " · [/] chapter · c chapters" };
format!(
"<div class=\"cast\" data-proc=\"{idx}\">\n{summary_html}<div class=\"cast-toolbar\">\
<span class=\"cast-keys dim\">space · ←/→ seek · </> speed{chapter_keys} · f fullscreen</span></div>\n\
<div class=\"cast-player\"></div>\n</div>\n{diff}",
idx = proc.index,
chapter_keys = chapter_keys,
diff = diff_embed_html(diff),
)
}
CastExport::Note { .. } if !proc.lines.is_empty() => {
let mut lines_html = String::new();
for line in &proc.lines {
lines_html.push_str(&format!(
"<div class=\"line\"><span class=\"at\">+{at:.1}s</span> {text}</div>\n",
at = line.at,
text = esc(&line.text)
));
}
format!("<div class=\"output\">{lines_html}</div>\n{}", diff_embed_html(diff))
}
CastExport::Note { text, .. } => {
format!("<div class=\"detail dim\">{}</div>\n{}", esc(text), diff_embed_html(diff))
}
};
format!(
r#"<details open class="proc {status}" data-index="{idx}"{task_attrs}>
<summary>
<span class="triangle" aria-hidden="true"></span>
<span class="label">{label}</span>
<span class="meta">{elapsed}</span>
<span class="note dim">{note}</span>
{diff_chip}</summary>
{meta}
{body}</details>
"#,
status = proc.status.as_str(),
idx = proc.index,
task_attrs = proc_task_attrs(session, proc),
label = esc(&proc.label),
elapsed = esc(&elapsed),
note = esc(note),
diff_chip = diff_chip_html(diff.is_some()),
meta = proc_meta_html(proc),
)
}