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.summary.isEmpty {
out += "## Summary\n\n\(x.tldr.summary)\n"
if !x.tldr.primarySpeakers.isEmpty { out += "\n*Primary speakers: \(x.tldr.primarySpeakers.joined(separator: ", "))*\n" }
out += "\n"
}
if !x.decisions.isEmpty {
out += "## Decisions\n\n"
for d in x.decisions {
var line = "- \(d.statement)"
if !d.agreedBy.isEmpty { line += " — agreed by \(d.agreedBy.joined(separator: ", "))" }
if let o = d.supportingOffset { line += " *(\(RecapAnalyzer.mmss(Double(o))))*" }
out += line + "\n"
}
out += "\n"
}
if !x.actionItems.isEmpty {
out += "## Action Items\n\n"
for a in x.actionItems {
var line = "- [ ] \(a.description)"
if let o = a.owner { line += " — **\(o)**" }
if let due = a.dueHint { line += " (\(due))" }
if let off = a.supportingOffset { line += " *(\(RecapAnalyzer.mmss(Double(off))))*" }
out += line + "\n"
}
out += "\n"
}
if !x.openQuestions.isEmpty {
out += "## Open Questions\n\n"
for q in x.openQuestions {
var line = "- \(q.question)"
if let r = q.raisedBy { line += " — *\(r)*" }
out += line + "\n"
}
out += "\n"
}
if !x.keyQuotes.isEmpty {
out += "## Key Quotes\n\n"
for k in x.keyQuotes {
out += "> \"\(k.quote)\""
var attr: [String] = []
if let s = k.speaker { attr.append(s) }
if let o = k.offset { attr.append(RecapAnalyzer.mmss(Double(o))) }
if !attr.isEmpty { out += " — \(attr.joined(separator: ", "))" }
out += "\n"
if !k.whyNotable.isEmpty { out += ">\n> \(k.whyNotable)\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 += "Transcript
\n\n"
out += transcriptLines(sec, entries: entries)
out += "\n\(esc(title))
\(esc(x.tldr.summary))
" + (x.tldr.primarySpeakers.isEmpty ? "" : "Primary: \(x.tldr.primarySpeakers.map(esc).joined(separator: ", "))
")) } if !x.decisions.isEmpty { let items = x.decisions.map { d -> String in var s = "“\(esc(k.quote))”" if !attr.isEmpty { s += "— \(attr.joined(separator: ", "))" } if !k.whyNotable.isEmpty { s += "" }.joined() body += card("Key Quotes", items) } } if !result.sections.isEmpty { var topics = "" for (i, sec) in result.sections.enumerated() { let range = entries.indices.contains(sec.startIndex) && entries.indices.contains(sec.endIndex) ? "\(RecapAnalyzer.mmss(entries[sec.startIndex].offset))–\(RecapAnalyzer.mmss(entries[sec.endIndex].end))" : "" topics += "\(esc(k.whyNotable))" } return s + "
\(esc(sec.summary))
" } topics += "