diff --git a/Ten31Transcripts/Recap/RecapRenderer.swift b/Ten31Transcripts/Recap/RecapRenderer.swift index 855f68a..c683a4f 100644 --- a/Ten31Transcripts/Recap/RecapRenderer.swift +++ b/Ten31Transcripts/Recap/RecapRenderer.swift @@ -82,6 +82,10 @@ enum RecapRenderer { // 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) @@ -91,66 +95,78 @@ enum RecapRenderer { return "\(esc(name))" } - var body = "" + // Header: title, meta line, speaker legend. let sub = "\(esc(file.app)) · \(RecapAnalyzer.mmss(file.durationSec))" + (speakers.isEmpty ? "" : " · \(speakers.count) speaker\(speakers.count == 1 ? "" : "s")") - body += "

\(esc(title))

\(sub)
" + var header = "

\(esc(title))

\(sub)
" if !speakers.isEmpty { - body += "
" + speakers.map { chip($0) }.joined() + "
" + header += "
" + speakers.map { chip($0) }.joined() + "
" } - body += "
" + header += "" + // Recap cards band (summary + template takeaways). + var cards = "" if let x = result.extras { if !x.tldr.isEmpty { - body += card("Summary", "

\(esc(x.tldr))

" + 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: - body += card(section.title, "

\(esc(section.paragraph))

") + cards += card(section.title, "

\(esc(section.paragraph))

") case .bullets: - body += card(section.title, "") + 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)))" } + 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, "") + cards += card(section.title, "") } } } + let band = cards.isEmpty ? "" : "
    \(cards)
    " - 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) + "
    " + // 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))
    ") + + "
    " } - body += card("Topics", topics) } + left += "
    " - let full = entries.map { "
    \(RecapAnalyzer.mmss($0.offset)) \(chip($0.speaker)) \(esc($0.text))
    " }.joined() - body += "
    Full Transcript
    \(full)
    " + // 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 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)
    " } @@ -176,34 +192,63 @@ enum RecapRenderer { \(title) -
    \(body) - -
    + \(body) +
    Ten31 Transcripts · generated on-device
    + + """ } }