Files
ten31-transcripts/Ten31Transcripts/Recap/RecapRenderer.swift
T
Grant Gilliam 3bb7f1ab32 Restyle recap.html to match recap-relay
The recap output looked notably different from recap-relay (bigger 15px font,
different palette, single-column cards). Match recap-relay's job-output view:
slate/indigo palette (--bg #0a0e1a, --accent #818cf8), 13px base type with the
Helvetica/Arial stack, monospace accent-soft timestamps, and the two-pane layout
— topic list on the left, full diarized transcript on the right, click a topic to
scroll + highlight its range (inline JS, data baked in; no backend fetch). The
summary/takeaways render as recap-relay-style cards in a band above the split.
markdown() output unchanged. 66 tests pass.
2026-06-08 21:00:56 -05:00

255 lines
14 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import Foundation
/// Renders a session (`speakers.json` + recap) into human-readable artifacts:
/// `transcript.md` (portable/editable) and `recap.html` (self-contained, dark
/// theme, printable, shareable). Port of recap-relay's meetingToMarkdown/Html,
/// driven by our already-named transcript.
enum RecapRenderer {
static func write(file: SpeakersFile, result: RecapResult, title: String, to folder: URL) throws {
let entries = RecapAnalyzer.entries(from: file)
try markdown(file: file, result: result, title: title, entries: entries)
.data(using: .utf8)?.write(to: folder.appendingPathComponent("transcript.md"))
try html(file: file, result: result, title: title, entries: entries)
.data(using: .utf8)?.write(to: folder.appendingPathComponent("recap.html"))
}
// MARK: - Markdown
static func markdown(file: SpeakersFile, result: RecapResult, title: String,
entries: [RecapAnalyzer.Entry]) -> String {
var out = "# \(title)\n\n"
let speakers = RecapAnalyzer.orderedSpeakerNames(entries)
out += "*\(file.app) · \(RecapAnalyzer.mmss(file.durationSec))"
if !speakers.isEmpty { out += " · \(speakers.count) speaker\(speakers.count == 1 ? "" : "s"): \(speakers.joined(separator: ", "))" }
out += "*\n\n"
if let x = result.extras {
if !x.tldr.isEmpty {
out += "## Summary\n\n\(x.tldr)\n"
if !x.primarySpeakers.isEmpty { out += "\n*Primary speakers: \(x.primarySpeakers.joined(separator: ", "))*\n" }
out += "\n"
}
for section in x.sections where !section.isEmpty {
out += "## \(section.title)\n\n"
switch section.kind {
case .paragraph:
out += "\(section.paragraph)\n\n"
case .bullets:
for b in section.bullets { out += "- \(b)\n" }
out += "\n"
case .items:
for item in section.items {
var line = "- \(item.text)"
if let who = item.who { line += " — **\(who)**" }
if let note = item.note { line += " (\(note))" }
if let when = item.when { line += " *(\(RecapAnalyzer.mmss(Double(when))))*" }
out += line + "\n"
}
out += "\n"
}
}
}
if !result.sections.isEmpty {
out += "## Topics\n\n"
for (i, sec) in result.sections.enumerated() {
let range = timeRange(sec, entries: entries)
out += "### \(i + 1). \(sec.title)\(range)\n\n"
if !sec.summary.isEmpty { out += "\(sec.summary)\n\n" }
out += "<details>\n<summary>Transcript</summary>\n\n"
out += transcriptLines(sec, entries: entries)
out += "\n</details>\n\n"
}
}
out += "## Full Transcript\n\n"
for e in entries { out += "**[\(RecapAnalyzer.mmss(e.offset))] \(e.speaker):** \(e.text)\n\n" }
return out
}
private static func timeRange(_ sec: TopicSection, entries: [RecapAnalyzer.Entry]) -> String {
guard entries.indices.contains(sec.startIndex), entries.indices.contains(sec.endIndex) else { return "" }
return " *(\(RecapAnalyzer.mmss(entries[sec.startIndex].offset))\(RecapAnalyzer.mmss(entries[sec.endIndex].end)))*"
}
private static func transcriptLines(_ sec: TopicSection, entries: [RecapAnalyzer.Entry]) -> String {
guard sec.startIndex <= sec.endIndex, entries.indices.contains(sec.startIndex), entries.indices.contains(sec.endIndex)
else { return "" }
return entries[sec.startIndex...sec.endIndex]
.map { "**[\(RecapAnalyzer.mmss($0.offset))] \($0.speaker):** \($0.text)" }
.joined(separator: "\n\n")
}
// MARK: - HTML
/// Mirror of recap-relay's job-output view: a header, an optional band of recap
/// cards (summary + takeaways), then a two-pane split topic list on the left,
/// full diarized transcript on the right, click a topic to jump + highlight its
/// range. Self-contained (data baked in; the click handler is inline JS).
static func html(file: SpeakersFile, result: RecapResult, title: String,
entries: [RecapAnalyzer.Entry]) -> String {
let speakers = RecapAnalyzer.orderedSpeakerNames(entries)
let colorFor = speakerColors(speakers)
func chip(_ name: String) -> String {
let c = colorFor[name] ?? "#8a8f98"
return "<span class=\"chip\" style=\"background:\(c)\">\(esc(name))</span>"
}
// Header: title, meta line, speaker legend.
let sub = "\(esc(file.app)) · \(RecapAnalyzer.mmss(file.durationSec))"
+ (speakers.isEmpty ? "" : " · \(speakers.count) speaker\(speakers.count == 1 ? "" : "s")")
var header = "<div class=\"header\"><div class=\"htext\"><h1>\(esc(title))</h1><div class=\"meta\">\(sub)</div></div>"
if !speakers.isEmpty {
header += "<div class=\"legend\">" + speakers.map { chip($0) }.joined() + "</div>"
}
header += "</div>"
// Recap cards band (summary + template takeaways).
var cards = ""
if let x = result.extras {
if !x.tldr.isEmpty {
cards += card("Summary", "<p>\(esc(x.tldr))</p>"
+ (x.primarySpeakers.isEmpty ? "" : "<p class=\"muted\">Primary: \(x.primarySpeakers.map(esc).joined(separator: ", "))</p>"))
}
for section in x.sections where !section.isEmpty {
switch section.kind {
case .paragraph:
cards += card(section.title, "<p>\(esc(section.paragraph))</p>")
case .bullets:
cards += card(section.title, "<ul>" + section.bullets.map { "<li>\(esc($0))</li>" }.joined() + "</ul>")
case .items:
let lis = section.items.map { item -> String in
var s = "<li>\(esc(item.text))"
if let who = item.who { s += " <span class=\"who\">\(esc(who))</span>" }
if let note = item.note { s += " <span class=\"note\">(\(esc(note)))</span>" }
if let when = item.when { s += " <span class=\"ts-badge\">\(RecapAnalyzer.mmss(Double(when)))</span>" }
return s + "</li>"
}.joined()
cards += card(section.title, "<ul>\(lis)</ul>")
}
}
}
let band = cards.isEmpty ? "" : "<div class=\"band\">\(cards)</div>"
// Left pane: topic cards (click to jump). data-start/data-end index entries.
var left = "<div class=\"left\">"
if result.sections.isEmpty {
left += "<div class=\"empty\">No topic sections.</div>"
} else {
for sec in result.sections {
let s = max(0, min(sec.startIndex, entries.count - 1))
let e = max(s, min(sec.endIndex, entries.count - 1))
let time = entries.indices.contains(s) && entries.indices.contains(e)
? "<span class=\"chunk-time\">\(RecapAnalyzer.mmss(entries[s].offset))\(RecapAnalyzer.mmss(entries[e].end))</span>" : ""
left += "<div class=\"chunk\" data-start=\"\(s)\" data-end=\"\(e)\" onclick=\"jump(this)\">"
+ "<div class=\"chunk-title\">\(esc(sec.title))\(time)</div>"
+ (sec.summary.isEmpty ? "" : "<div class=\"chunk-summary\">\(esc(sec.summary))</div>")
+ "</div>"
}
}
left += "</div>"
// Right pane: full diarized transcript, one line per turn (id=entry-i).
var right = "<div class=\"right\">"
if entries.isEmpty {
right += "<div class=\"empty\">No transcript.</div>"
} else {
for (i, en) in entries.enumerated() {
right += "<div class=\"transcript-line\" id=\"entry-\(i)\">"
+ "<span class=\"ts-badge\">\(RecapAnalyzer.mmss(en.offset))</span>"
+ chip(en.speaker)
+ "<span class=\"ts-text\">\(esc(en.text))</span></div>"
}
}
right += "</div>"
let body = header + band + "<div class=\"split\">\(left)\(right)</div>"
return htmlShell(title: esc(title), body: body)
}
private static func card(_ title: String, _ inner: String) -> String {
"<section class=\"card\"><h2>\(esc(title))</h2>\(inner)</section>"
}
private static let palette = ["#5b8def", "#e0719c", "#43b581", "#e8a33d", "#9b6dde",
"#3fb6c9", "#d96f6f", "#7aa55c"]
private static func speakerColors(_ names: [String]) -> [String: String] {
var map: [String: String] = [:]
for (i, n) in names.enumerated() { map[n] = palette[i % palette.count] }
return map
}
private static func esc(_ s: String) -> String {
s.replacingOccurrences(of: "&", with: "&amp;")
.replacingOccurrences(of: "<", with: "&lt;")
.replacingOccurrences(of: ">", with: "&gt;")
.replacingOccurrences(of: "\"", with: "&quot;")
}
private static func htmlShell(title: String, body: String) -> String {
"""
<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>\(title)</title>
<style>
:root{--bg:#0a0e1a;--panel:#111827;--panel-2:#1e293b;--line:#1e293b;--line-2:#334155;
--fg:#e2e8f0;--fg-dim:#94a3b8;--fg-faint:#64748b;--accent:#818cf8;--accent-soft:#a5b4fc;}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--fg);min-height:100vh;
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif;font-size:13px;line-height:1.55}
.header{padding:14px 24px;background:var(--panel);border-bottom:1px solid var(--line);
display:flex;align-items:center;gap:16px;flex-wrap:wrap}
.header .htext{min-width:0}
.header h1{margin:0;font-size:16px;font-weight:700;color:var(--fg)}
.header .meta{font-size:11px;color:var(--fg-faint);margin-top:2px;font-variant-numeric:tabular-nums}
.legend{margin-left:auto;display:flex;flex-wrap:wrap;gap:6px;justify-content:flex-end}
.chip{display:inline-block;padding:1px 8px;border-radius:999px;color:#fff;font-size:10px;font-weight:700;white-space:nowrap}
.band{padding:16px 24px;display:grid;gap:12px}
.card{background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:14px 16px}
.card h2{margin:0 0 8px;font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.04em;color:var(--accent-soft)}
.card p{margin:0 0 8px}
.card p:last-child{margin-bottom:0}
.card .muted{color:var(--fg-dim);font-size:12px}
.card ul{margin:0;padding-left:18px}
.card li{margin:5px 0;color:var(--fg)}
.card .who{color:var(--accent-soft);font-weight:600}
.card .note{color:var(--fg-faint)}
.split{display:flex;min-height:calc(100vh - 56px)}
.left{flex:0 0 42%;max-width:42%;border-right:1px solid var(--line);overflow-y:auto;padding:16px;background:var(--bg)}
.right{flex:1;min-width:0;overflow-y:auto;padding:16px;background:var(--panel)}
@media(max-width:900px){.split{flex-direction:column}.left,.right{flex:none;max-width:100%;border-right:none}
.left{border-bottom:1px solid var(--line)}}
.chunk{padding:12px 14px;margin-bottom:8px;background:var(--panel);border:1px solid var(--line);
border-radius:10px;cursor:pointer;transition:border-color .15s,background .15s}
.chunk:hover{border-color:var(--accent)}
.chunk.active{border-color:var(--accent);background:rgba(129,140,248,.06);box-shadow:0 2px 16px rgba(129,140,248,.10)}
.chunk-title{font-size:13px;font-weight:700;color:var(--fg);margin-bottom:4px}
.chunk-time{font-size:10px;color:var(--fg-faint);margin-left:6px;font-weight:500;font-family:"SF Mono",Menlo,monospace}
.chunk-summary{font-size:12px;color:var(--fg-dim);line-height:1.5}
.transcript-line{display:flex;gap:10px;padding:4px 8px;border-radius:6px;line-height:1.6;align-items:baseline;scroll-margin-top:16px}
.transcript-line.hl{background:rgba(129,140,248,.10)}
.ts-badge{flex:0 0 auto;font-family:"SF Mono",Menlo,monospace;font-size:11px;color:var(--accent-soft);min-width:52px}
.ts-text{flex:1;font-size:13px;color:var(--fg)}
.empty{padding:32px 16px;text-align:center;color:var(--fg-faint)}
.foot{padding:14px 24px;color:var(--fg-faint);font-size:11px;border-top:1px solid var(--line)}
@media print{body{background:#fff;color:#000}.header,.right,.left,.card,.chunk{background:#fff;border-color:#ccc}
.split{display:block}.left,.right{max-width:100%}.chip{border:1px solid #999}}
</style></head>
<body>\(body)
<div class="foot">Ten31 Transcripts · generated on-device</div>
<script>
function jump(el){
document.querySelectorAll('.chunk.active').forEach(function(x){x.classList.remove('active')});
el.classList.add('active');
var s=+el.dataset.start, e=+el.dataset.end;
var t=document.getElementById('entry-'+s);
if(t) t.scrollIntoView({behavior:'smooth',block:'start'});
document.querySelectorAll('.transcript-line.hl').forEach(function(x){x.classList.remove('hl')});
for(var i=s;i<=e;i++){var x=document.getElementById('entry-'+i); if(x) x.classList.add('hl');}
}
</script>
</body></html>
"""
}
}