2026-05-24 22:55:56 +00:00
|
|
|
|
package render
|
|
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
|
"fmt"
|
|
|
|
|
|
"math"
|
|
|
|
|
|
"strings"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
// MarkdownRenderer renders transcript artifacts as Markdown.
|
|
|
|
|
|
type MarkdownRenderer struct{}
|
|
|
|
|
|
|
|
|
|
|
|
// Render renders the transcript into deterministic Markdown.
|
|
|
|
|
|
func (MarkdownRenderer) Render(transcript Transcript, opts Options) (string, error) {
|
|
|
|
|
|
var lines []string
|
|
|
|
|
|
|
|
|
|
|
|
title := strings.TrimSpace(opts.Title)
|
|
|
|
|
|
if title == "" {
|
|
|
|
|
|
title = "Transcript"
|
|
|
|
|
|
}
|
2026-05-24 18:13:05 -05:00
|
|
|
|
lines = append(lines, "# "+escapeMarkdownInline(title), "")
|
2026-05-24 22:55:56 +00:00
|
|
|
|
|
|
|
|
|
|
if opts.IncludeMetadata {
|
|
|
|
|
|
lines = append(lines,
|
2026-05-24 18:13:05 -05:00
|
|
|
|
fmt.Sprintf("- Application: %s", escapeMarkdownInline(transcript.Metadata.Application)),
|
|
|
|
|
|
fmt.Sprintf("- Version: %s", escapeMarkdownInline(transcript.Metadata.Version)),
|
|
|
|
|
|
fmt.Sprintf("- Output schema: %s", escapeMarkdownInline(transcript.Schema)),
|
2026-05-24 22:55:56 +00:00
|
|
|
|
"",
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
for _, segment := range transcript.Segments {
|
|
|
|
|
|
parts := make([]string, 0, 4)
|
|
|
|
|
|
if opts.IncludeTimestamps {
|
|
|
|
|
|
parts = append(parts, fmt.Sprintf("[%s–%s]", formatTimestamp(segment.Start), formatTimestamp(segment.End)))
|
|
|
|
|
|
}
|
|
|
|
|
|
if opts.IncludeSegmentIDs {
|
|
|
|
|
|
parts = append(parts, fmt.Sprintf("[#%d]", segment.ID))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-24 18:13:05 -05:00
|
|
|
|
text := escapeMarkdownInline(segment.Text)
|
2026-05-24 22:55:56 +00:00
|
|
|
|
if shouldItalicize(segment.Categories) {
|
|
|
|
|
|
text = "*" + text + "*"
|
|
|
|
|
|
}
|
2026-05-24 18:13:05 -05:00
|
|
|
|
parts = append(parts, fmt.Sprintf("**%s:** %s", escapeMarkdownInline(segment.Speaker), text))
|
2026-05-24 22:55:56 +00:00
|
|
|
|
lines = append(lines, strings.Join(parts, " "))
|
|
|
|
|
|
lines = append(lines, "")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
output := strings.Join(lines, "\n")
|
|
|
|
|
|
if !strings.HasSuffix(output, "\n") {
|
|
|
|
|
|
output += "\n"
|
|
|
|
|
|
}
|
|
|
|
|
|
return output, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-24 18:13:05 -05:00
|
|
|
|
func escapeMarkdownInline(value string) string {
|
|
|
|
|
|
replacer := strings.NewReplacer(
|
|
|
|
|
|
`\`, `\\`,
|
|
|
|
|
|
"`", "\\`",
|
|
|
|
|
|
"*", "\\*",
|
|
|
|
|
|
"_", "\\_",
|
|
|
|
|
|
"{", "\\{",
|
|
|
|
|
|
"}", "\\}",
|
|
|
|
|
|
"[", "\\[",
|
|
|
|
|
|
"]", "\\]",
|
|
|
|
|
|
"(", "\\(",
|
|
|
|
|
|
")", "\\)",
|
|
|
|
|
|
"#", "\\#",
|
|
|
|
|
|
"+", "\\+",
|
|
|
|
|
|
"!", "\\!",
|
|
|
|
|
|
"|", "\\|",
|
|
|
|
|
|
"<", "\\<",
|
|
|
|
|
|
">", "\\>",
|
|
|
|
|
|
)
|
|
|
|
|
|
return replacer.Replace(value)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-24 22:55:56 +00:00
|
|
|
|
func shouldItalicize(categories []string) bool {
|
|
|
|
|
|
for _, category := range categories {
|
|
|
|
|
|
switch category {
|
|
|
|
|
|
case "background", "backchannel", "filler":
|
|
|
|
|
|
return true
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func formatTimestamp(seconds float64) string {
|
|
|
|
|
|
total := int(math.Round(seconds))
|
|
|
|
|
|
if total < 0 {
|
|
|
|
|
|
total = 0
|
|
|
|
|
|
}
|
|
|
|
|
|
hours := total / 3600
|
|
|
|
|
|
minutes := (total % 3600) / 60
|
|
|
|
|
|
remainder := total % 60
|
|
|
|
|
|
return fmt.Sprintf("%02d:%02d:%02d", hours, minutes, remainder)
|
|
|
|
|
|
}
|