feat(ios): export chat transcripts as Markdown via the share sheet (#100417)

* feat(ios): export chat transcripts as Markdown via the share sheet

* chore(ios): sync native i18n inventory
This commit is contained in:
Peter Steinberger
2026-07-05 22:14:44 +01:00
committed by GitHub
parent aa66581749
commit 8c19dbfb62
6 changed files with 400 additions and 13 deletions
@@ -0,0 +1,162 @@
import Foundation
public enum ChatTranscriptExporter {
private static let maxFileStemUTF8Count = 240
public static func markdown(
sessionTitle: String?,
sessionKey: String,
messages: [OpenClawChatMessage]) -> String
{
let title = self.resolvedTitle(sessionTitle: sessionTitle, sessionKey: sessionKey)
let timestampFormatter = ISO8601DateFormatter()
timestampFormatter.formatOptions = [.withInternetDateTime]
timestampFormatter.timeZone = TimeZone(secondsFromGMT: 0)
var sections = ["# \(title)"]
for message in messages where self.shouldExport(message) {
let timestamp = self.timestamp(message.timestamp, formatter: timestampFormatter)
let heading = "### \(self.displayRole(message.role))\(timestamp)"
let body = self.body(for: message)
sections.append([heading, body].filter { !$0.isEmpty }.joined(separator: "\n\n"))
}
return sections.joined(separator: "\n\n") + "\n"
}
public static func filename(sessionTitle: String?, sessionKey: String) -> String {
let title = sessionTitle?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let key = sessionKey.trimmingCharacters(in: .whitespacesAndNewlines)
let stem = self.sanitizedFileStem(title)
?? self.sanitizedFileStem(key)
?? "chat-transcript"
let boundedStem = self.boundedFileStem(stem)
return "\(boundedStem.isEmpty ? "chat-transcript" : boundedStem).md"
}
private static func sanitizedFileStem(_ value: String) -> String? {
let forbidden = CharacterSet(charactersIn: "/\\:*?\"<>|").union(.controlCharacters)
var segments: [String] = []
var current = ""
for scalar in value.unicodeScalars {
if forbidden.contains(scalar) {
segments.append(current)
current = ""
} else {
current.unicodeScalars.append(scalar)
}
}
segments.append(current)
let edgeCharacters = CharacterSet.whitespacesAndNewlines.union(CharacterSet(charactersIn: ".-"))
let stem = segments
.map { segment in
segment
.split(whereSeparator: { $0.isWhitespace })
.joined(separator: " ")
.trimmingCharacters(in: edgeCharacters)
}
.filter { !$0.isEmpty }
.joined(separator: "-")
return stem.isEmpty ? nil : stem
}
private static func boundedFileStem(_ stem: String) -> String {
var result = ""
for character in stem {
let candidate = result + String(character)
guard candidate.utf8.count <= self.maxFileStemUTF8Count else { break }
result = candidate
}
return result
}
private static func resolvedTitle(sessionTitle: String?, sessionKey: String) -> String {
let title = sessionTitle?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if !title.isEmpty {
return title.split(whereSeparator: { $0.isNewline }).joined(separator: " ")
}
let key = sessionKey.trimmingCharacters(in: .whitespacesAndNewlines)
return key.isEmpty ? "Chat transcript" : key.split(whereSeparator: { $0.isNewline }).joined(separator: " ")
}
private static func shouldExport(_ message: OpenClawChatMessage) -> Bool {
let role = message.role.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
guard role != "system" else { return false }
if !self.attachments(in: message).isEmpty {
return true
}
let text = self.visibleText(in: message)
guard !text.isEmpty else { return false }
return role == "user" || AssistantTextParser.hasVisibleContent(in: text)
}
private static func body(for message: OpenClawChatMessage) -> String {
var parts: [String] = []
let text = self.visibleText(in: message)
if !text.isEmpty {
parts.append(text)
}
parts.append(contentsOf: self.attachments(in: message).map { attachment in
let filename = attachment.fileName?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return "_[attachment: \(filename.isEmpty ? "Attachment" : filename)]_"
})
return parts.joined(separator: "\n\n")
}
private static func primaryText(in message: OpenClawChatMessage) -> String {
let parts = message.content.compactMap { content -> String? in
let kind = (content.type ?? "text").lowercased()
guard kind == "text" || kind.isEmpty else { return nil }
return content.text
}
return OpenClawChatMessage.displayText(
contentText: parts.joined(separator: "\n"),
role: message.role,
stopReason: message.stopReason,
errorMessage: message.errorMessage)
}
private static func visibleText(in message: OpenClawChatMessage) -> String {
let text = self.primaryText(in: message)
let role = message.role.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
guard role != "user" else { return text }
return AssistantTextParser.visibleSegments(from: text)
.map(\.text)
.joined(separator: "\n\n")
}
private static func attachments(in message: OpenClawChatMessage) -> [OpenClawChatMessageContent] {
message.content.filter { content in
let kind = (content.type ?? "text").lowercased()
return kind == "file" || kind == "attachment"
}
}
private static func displayRole(_ role: String) -> String {
let normalized = role.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
return switch normalized {
case "user": "User"
case "assistant": "Assistant"
default:
normalized.isEmpty
? "Message"
: normalized.prefix(1).uppercased() + normalized.dropFirst()
}
}
private static func timestamp(_ value: Double?, formatter: ISO8601DateFormatter) -> String {
guard let value else { return "Unknown time" }
return formatter.string(from: Date(timeIntervalSince1970: value / 1000))
}
}
extension OpenClawChatViewModel {
public func exportTranscriptMarkdown() -> String {
let title = self.sessions.first { $0.key == self.sessionKey }?.displayName
return ChatTranscriptExporter.markdown(
sessionTitle: title,
sessionKey: self.sessionKey,
messages: self.messages)
}
}
@@ -0,0 +1,105 @@
import Testing
@testable import OpenClawChatUI
@Suite("ChatTranscriptExporter")
struct ChatTranscriptExporterTests {
@Test func `formats visible messages and attachments`() {
let messages = [
self.message(role: "system", text: "Hidden setup", timestamp: 0),
self.message(role: "user", text: "Hello, 世界 👋", timestamp: 0),
self.message(
role: "assistant",
text: """
<think>Do not export this reasoning.</think><final>
| Item | Value |
| --- | --- |
| code | `ok` |
```swift
print("hi")
```
</final>
""",
timestamp: 1000),
OpenClawChatMessage(
role: "user",
content: [
OpenClawChatMessageContent(
type: "attachment",
text: nil,
mimeType: "text/plain",
fileName: "résumé.txt",
content: nil),
],
timestamp: 2000),
self.message(role: "assistant", text: " ", timestamp: 3000),
]
let markdown = ChatTranscriptExporter.markdown(
sessionTitle: "Project 🐾",
sessionKey: "agent:main",
messages: messages)
#expect(markdown == """
# Project 🐾
### User — 1970-01-01T00:00:00Z
Hello, 世界 👋
### Assistant — 1970-01-01T00:00:01Z
| Item | Value |
| --- | --- |
| code | `ok` |
```swift
print("hi")
```
### User — 1970-01-01T00:00:02Z
_[attachment: résumé.txt]_
""")
}
@Test func `sanitizes filename`() {
#expect(
ChatTranscriptExporter.filename(
sessionTitle: " Project / Q3: \"Résumé\"? ",
sessionKey: "fallback") == "Project-Q3-Résumé.md")
#expect(
ChatTranscriptExporter.filename(
sessionTitle: "///",
sessionKey: "fallback") == "fallback.md")
let emojiFilename = ChatTranscriptExporter.filename(
sessionTitle: String(repeating: "🐾", count: 200),
sessionKey: "fallback")
#expect(emojiFilename.utf8.count <= 243)
#expect(emojiFilename.hasSuffix(".md"))
}
@Test func `empty transcript contains header only`() {
#expect(
ChatTranscriptExporter.markdown(
sessionTitle: nil,
sessionKey: "agent:main",
messages: []) == "# agent:main\n")
}
private func message(role: String, text: String, timestamp: Double) -> OpenClawChatMessage {
OpenClawChatMessage(
role: role,
content: [
OpenClawChatMessageContent(
type: "text",
text: text,
mimeType: nil,
fileName: nil,
content: nil),
],
timestamp: timestamp)
}
}