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 += "
\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.isEmpty { body += card("Summary", "

\(esc(x.tldr))

" + (x.primarySpeakers.isEmpty ? "" : "

Primary: \(x.primarySpeakers.map(esc).joined(separator: ", "))

")) } for section in x.sections where !section.isEmpty { switch section.kind { case .paragraph: body += card(section.title, "

\(esc(section.paragraph))

") case .bullets: body += card(section.title, "") case .items: let lis = section.items.map { item -> String in var s = "
  • \(esc(item.text))" if let who = item.who { s += " \(esc(who))" } if let note = item.note { s += " (\(esc(note)))" } if let when = item.when { s += " \(RecapAnalyzer.mmss(Double(when)))" } return s + "
  • " }.joined() body += card(section.title, "") } } } 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)
    """ } }