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 += "
\nTranscript\n\n" out += transcriptLines(sec, entries: entries) out += "\n
\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 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 "\(esc(name))" } var body = "" let sub = "\(esc(file.app)) · \(RecapAnalyzer.mmss(file.durationSec))" + (speakers.isEmpty ? "" : " · \(speakers.count) speaker\(speakers.count == 1 ? "" : "s")") body += "

\(esc(title))

\(sub)
" if !speakers.isEmpty { body += "
" + speakers.map { chip($0) }.joined() + "
" } body += "
" if let x = result.extras { if !x.tldr.summary.isEmpty { body += card("Summary", "

\(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(d.statement))" if !d.agreedBy.isEmpty { s += " — agreed by \(d.agreedBy.map(esc).joined(separator: ", "))" } if let o = d.supportingOffset { s += " \(RecapAnalyzer.mmss(Double(o)))" } return s + "
  • " }.joined() body += card("Decisions", "") } if !x.actionItems.isEmpty { let items = x.actionItems.map { a -> String in var s = "
  • ☐ \(esc(a.description))" if let o = a.owner { s += " \(esc(o))" } if let due = a.dueHint { s += " (\(esc(due)))" } if let off = a.supportingOffset { s += " \(RecapAnalyzer.mmss(Double(off)))" } return s + "
  • " }.joined() body += card("Action Items", "") } if !x.openQuestions.isEmpty { let items = x.openQuestions.map { q -> String in "
  • \(esc(q.question))" + (q.raisedBy.map { " — \(esc($0))" } ?? "") + "
  • " }.joined() body += card("Open Questions", "") } if !x.keyQuotes.isEmpty { let items = x.keyQuotes.map { k -> String in var attr: [String] = [] if let s = k.speaker { attr.append(esc(s)) } if let o = k.offset { attr.append(RecapAnalyzer.mmss(Double(o))) } var s = "
    “\(esc(k.quote))”" if !attr.isEmpty { s += "— \(attr.joined(separator: ", "))" } if !k.whyNotable.isEmpty { s += "
    \(esc(k.whyNotable))
    " } return 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 += "
    \(i + 1) \(esc(sec.title)) \(range)" if !sec.summary.isEmpty { topics += "

    \(esc(sec.summary))

    " } topics += "
    " + turnsHtml(sec, entries: entries, chip: chip) + "
    " } body += card("Topics", topics) } let full = entries.map { "
    \(RecapAnalyzer.mmss($0.offset)) \(chip($0.speaker)) \(esc($0.text))
    " }.joined() body += "
    Full Transcript
    \(full)
    " return htmlShell(title: esc(title), body: body) } private static func turnsHtml(_ sec: TopicSection, entries: [RecapAnalyzer.Entry], chip: (String) -> String) -> 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)) \(chip($0.speaker)) \(esc($0.text))
    " }.joined() } private static func card(_ title: String, _ inner: String) -> String { "

    \(esc(title))

    \(inner)
    " } 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: "&") .replacingOccurrences(of: "<", with: "<") .replacingOccurrences(of: ">", with: ">") .replacingOccurrences(of: "\"", with: """) } private static func htmlShell(title: String, body: String) -> String { """ \(title)
    \(body)
    """ } }