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 /// 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 "\(esc(name))" } // 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 = "

\(esc(title))

\(sub)
" if !speakers.isEmpty { header += "
" + speakers.map { chip($0) }.joined() + "
" } header += "
" // Recap cards band (summary + template takeaways). var cards = "" if let x = result.extras { if !x.tldr.isEmpty { cards += 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: cards += card(section.title, "

\(esc(section.paragraph))

") case .bullets: cards += 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() cards += card(section.title, "") } } } let band = cards.isEmpty ? "" : "
    \(cards)
    " // Left pane: topic cards (click to jump). data-start/data-end index entries. var left = "
    " if result.sections.isEmpty { left += "
    No topic sections.
    " } 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) ? "\(RecapAnalyzer.mmss(entries[s].offset)) — \(RecapAnalyzer.mmss(entries[e].end))" : "" left += "
    " + "
    \(esc(sec.title))\(time)
    " + (sec.summary.isEmpty ? "" : "
    \(esc(sec.summary))
    ") + "
    " } } left += "
    " // Right pane: full diarized transcript, one line per turn (id=entry-i). var right = "
    " if entries.isEmpty { right += "
    No transcript.
    " } else { for (i, en) in entries.enumerated() { right += "
    " + "\(RecapAnalyzer.mmss(en.offset))" + chip(en.speaker) + "\(esc(en.text))
    " } } right += "
    " let body = header + band + "
    \(left)\(right)
    " return htmlShell(title: esc(title), body: body) } 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)
    Ten31 Transcripts · generated on-device
    """ } }