Dark: added a prefers-color-scheme: dark CSS block, so HTML export adapts to the browser/OS theme it's later viewed in. PDF is baked at export time so it can't respond live — instead the offscreen WKWebView used for PDF rendering has its appearance explicitly set to NSApp.effectiveAppearance, matching whatever mode the app is in right now at export time. Tables: the renderer had no table support at all (previously documented as an intentional scope cut), so GFM pipe tables were falling through to the plain-paragraph path and showing up as literal "| --- | --- |" text. Added detection (a row line immediately followed by a valid dashes/colons separator row) plus alignment parsing from the separator's colons.
458 lines
18 KiB
Swift
458 lines
18 KiB
Swift
//
|
|
// ConversationExportService.swift
|
|
// oAI
|
|
//
|
|
// Shared conversation export: Markdown, HTML, and PDF
|
|
//
|
|
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
|
// Copyright (C) 2026 Rune Olsen
|
|
//
|
|
// This file is part of oAI.
|
|
//
|
|
// oAI is licensed under the PolyForm Noncommercial License 1.0.0.
|
|
// You may use, study, modify, and share it for any noncommercial
|
|
// purpose. Commercial use — including selling oAI or any part of
|
|
// it, standalone or bundled into another product or service —
|
|
// requires a separate commercial license from the copyright holder.
|
|
//
|
|
// See the LICENSE file or
|
|
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for
|
|
// the full license text. For commercial licensing, contact Rune
|
|
// Olsen via <https://oai.pm>.
|
|
|
|
|
|
import AppKit
|
|
import Foundation
|
|
import WebKit
|
|
|
|
enum ConversationExportService {
|
|
|
|
// MARK: - Markdown
|
|
|
|
nonisolated static func markdown(messages: [Message]) -> String {
|
|
messages.map { msg in
|
|
let header = msg.role == .user ? "**User**" : "**Assistant**"
|
|
return "\(header)\n\n\(msg.content)"
|
|
}.joined(separator: "\n\n---\n\n")
|
|
}
|
|
|
|
// MARK: - HTML
|
|
|
|
nonisolated static func html(name: String, messages: [Message]) -> String {
|
|
let body = messages.map { msg -> String in
|
|
let roleLabel = msg.role == .user ? "User" : "Assistant"
|
|
let roleClass = msg.role == .user ? "user" : "assistant"
|
|
let rendered = renderMarkdownBody(htmlEscape(msg.content))
|
|
return """
|
|
<div class="message \(roleClass)">
|
|
<div class="role">\(roleLabel)</div>
|
|
\(rendered)
|
|
</div>
|
|
"""
|
|
}.joined(separator: "\n")
|
|
|
|
return """
|
|
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<title>\(htmlEscape(name))</title>
|
|
<style>\(css)</style>
|
|
</head>
|
|
<body>
|
|
<h1>\(htmlEscape(name))</h1>
|
|
\(body)
|
|
</body>
|
|
</html>
|
|
"""
|
|
}
|
|
|
|
nonisolated private static let css = """
|
|
:root { color-scheme: light dark; }
|
|
body { font-family: -apple-system, BlinkMacSystemFont, "Helvetica Neue", sans-serif; \
|
|
color: #1a1a1a; background: #ffffff; max-width: 820px; margin: 40px auto; padding: 0 24px; \
|
|
line-height: 1.5; }
|
|
h1 { font-size: 24px; border-bottom: 1px solid #ddd; padding-bottom: 12px; }
|
|
.message { margin: 20px 0; padding: 12px 16px; border-radius: 8px; border-left: 4px solid transparent; }
|
|
.message.user { background: #f5f7fa; border-left-color: #4a90d9; }
|
|
.message.assistant { background: #fafafa; border-left-color: #8a8a8a; }
|
|
.role { font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; \
|
|
color: #666; margin-bottom: 8px; }
|
|
.message p { margin: 8px 0; }
|
|
.message h1, .message h2, .message h3, .message h4, .message h5, .message h6 { margin: 12px 0 6px; }
|
|
.message ul, .message ol { margin: 8px 0; padding-left: 24px; }
|
|
.message blockquote { margin: 8px 0; padding: 4px 12px; border-left: 3px solid #ccc; color: #555; }
|
|
.message code { font-family: "SF Mono", Menlo, Consolas, monospace; font-size: 0.9em; \
|
|
background: #eef0f2; padding: 1px 5px; border-radius: 4px; }
|
|
.message pre { background: #1e1e1e; color: #e8e8e8; padding: 12px 14px; border-radius: 6px; \
|
|
overflow-x: auto; }
|
|
.message pre code { background: none; padding: 0; color: inherit; }
|
|
.message hr { border: none; border-top: 1px solid #ddd; margin: 16px 0; }
|
|
.message a { color: #4a90d9; }
|
|
.message table { border-collapse: collapse; margin: 10px 0; width: 100%; }
|
|
.message th, .message td { border: 1px solid #ddd; padding: 6px 10px; text-align: left; }
|
|
.message th { background: #f0f2f4; font-weight: 600; }
|
|
@media (prefers-color-scheme: dark) {
|
|
body { color: #e8e8e8; background: #1c1c1e; }
|
|
h1 { border-bottom-color: #3a3a3c; }
|
|
.message.user { background: #24303d; border-left-color: #5aa2f0; }
|
|
.message.assistant { background: #262626; border-left-color: #9a9a9a; }
|
|
.role { color: #a8a8a8; }
|
|
.message blockquote { border-left-color: #555; color: #bbb; }
|
|
.message code { background: #2e2e2e; color: #e0e0e0; }
|
|
.message hr { border-top-color: #3a3a3c; }
|
|
.message a { color: #6fb1f0; }
|
|
.message th, .message td { border-color: #3a3a3c; }
|
|
.message th { background: #2a2a2c; }
|
|
}
|
|
"""
|
|
|
|
// MARK: - PDF
|
|
|
|
static func pdfData(name: String, messages: [Message]) async throws -> Data {
|
|
let htmlString = html(name: name, messages: messages)
|
|
let webView = WKWebView(frame: NSRect(x: 0, y: 0, width: 816, height: 1056))
|
|
// PDF content is baked at export time, so it can't respond to prefers-color-scheme
|
|
// live like the HTML export does — match whatever appearance the app is in right now.
|
|
webView.appearance = NSApp.effectiveAppearance
|
|
let delegate = PDFLoadDelegate()
|
|
webView.navigationDelegate = delegate
|
|
try await delegate.load(htmlString, in: webView)
|
|
|
|
return try await withCheckedThrowingContinuation { continuation in
|
|
webView.createPDF(configuration: WKPDFConfiguration()) { result in
|
|
continuation.resume(with: result)
|
|
}
|
|
}
|
|
}
|
|
|
|
private final class PDFLoadDelegate: NSObject, WKNavigationDelegate {
|
|
private var continuation: CheckedContinuation<Void, Error>?
|
|
|
|
func load(_ html: String, in webView: WKWebView) async throws {
|
|
try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
|
|
self.continuation = cont
|
|
webView.loadHTMLString(html, baseURL: nil)
|
|
}
|
|
}
|
|
|
|
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
|
continuation?.resume()
|
|
continuation = nil
|
|
}
|
|
|
|
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
|
|
continuation?.resume(throwing: error)
|
|
continuation = nil
|
|
}
|
|
|
|
func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
|
|
continuation?.resume(throwing: error)
|
|
continuation = nil
|
|
}
|
|
}
|
|
|
|
// MARK: - File writing
|
|
|
|
nonisolated static func writeToDownloads(_ content: String, filename: String) -> URL? {
|
|
let downloads = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first
|
|
?? FileManager.default.temporaryDirectory
|
|
let fileURL = downloads.appendingPathComponent(filename)
|
|
do {
|
|
try content.write(to: fileURL, atomically: true, encoding: .utf8)
|
|
return fileURL
|
|
} catch {
|
|
return nil
|
|
}
|
|
}
|
|
|
|
nonisolated static func writeToDownloads(_ data: Data, filename: String) -> URL? {
|
|
let downloads = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first
|
|
?? FileManager.default.temporaryDirectory
|
|
let fileURL = downloads.appendingPathComponent(filename)
|
|
do {
|
|
try data.write(to: fileURL, options: .atomic)
|
|
return fileURL
|
|
} catch {
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// MARK: - Markdown → HTML rendering (scoped to what chat messages actually contain —
|
|
// headers, bold/italic, inline code, fenced code blocks, lists, blockquotes, links,
|
|
// horizontal rules, tables, paragraphs. Not full CommonMark/GFM.)
|
|
|
|
nonisolated private static func htmlEscape(_ text: String) -> String {
|
|
var result = text
|
|
result = result.replacingOccurrences(of: "&", with: "&")
|
|
result = result.replacingOccurrences(of: "<", with: "<")
|
|
result = result.replacingOccurrences(of: ">", with: ">")
|
|
result = result.replacingOccurrences(of: "\"", with: """)
|
|
result = result.replacingOccurrences(of: "'", with: "'")
|
|
return result
|
|
}
|
|
|
|
/// Renders already-HTML-escaped markdown text into an HTML body fragment.
|
|
nonisolated static func renderMarkdownBody(_ escapedContent: String) -> String {
|
|
let lines = escapedContent.components(separatedBy: "\n")
|
|
var html = ""
|
|
var index = 0
|
|
var paragraphLines: [String] = []
|
|
var listBuffer: [String] = []
|
|
var listTag: String?
|
|
|
|
func flushParagraph() {
|
|
guard !paragraphLines.isEmpty else { return }
|
|
let joined = paragraphLines.joined(separator: "<br>\n")
|
|
html += "<p>\(renderInline(joined))</p>\n"
|
|
paragraphLines = []
|
|
}
|
|
|
|
func flushList() {
|
|
guard let tag = listTag, !listBuffer.isEmpty else { return }
|
|
html += "<\(tag)>\n"
|
|
for item in listBuffer { html += "<li>\(renderInline(item))</li>\n" }
|
|
html += "</\(tag)>\n"
|
|
listBuffer = []
|
|
listTag = nil
|
|
}
|
|
|
|
while index < lines.count {
|
|
let line = lines[index].trimmingCharacters(in: .whitespaces)
|
|
|
|
// Fenced code block
|
|
if line.hasPrefix("```") {
|
|
flushParagraph(); flushList()
|
|
let lang = String(line.dropFirst(3)).trimmingCharacters(in: .whitespaces)
|
|
var codeLines: [String] = []
|
|
index += 1
|
|
while index < lines.count, !lines[index].trimmingCharacters(in: .whitespaces).hasPrefix("```") {
|
|
codeLines.append(lines[index])
|
|
index += 1
|
|
}
|
|
let classAttr = lang.isEmpty ? "" : " class=\"language-\(lang)\""
|
|
html += "<pre><code\(classAttr)>\(codeLines.joined(separator: "\n"))</code></pre>\n"
|
|
if index < lines.count { index += 1 } // skip closing ```
|
|
continue
|
|
}
|
|
|
|
// Headers
|
|
if let header = headerMatch(line) {
|
|
flushParagraph(); flushList()
|
|
html += "<h\(header.level)>\(renderInline(header.text))</h\(header.level)>\n"
|
|
index += 1
|
|
continue
|
|
}
|
|
|
|
// Horizontal rule
|
|
if isHorizontalRule(line) {
|
|
flushParagraph(); flushList()
|
|
html += "<hr>\n"
|
|
index += 1
|
|
continue
|
|
}
|
|
|
|
// GFM-style pipe table: a row line immediately followed by a valid separator row
|
|
if line.contains("|"), index + 1 < lines.count,
|
|
isTableSeparatorRow(lines[index + 1].trimmingCharacters(in: .whitespaces)) {
|
|
flushParagraph(); flushList()
|
|
let headerCells = splitTableRow(line)
|
|
let alignments = tableAlignments(from: lines[index + 1].trimmingCharacters(in: .whitespaces))
|
|
index += 2
|
|
var bodyRows: [[String]] = []
|
|
while index < lines.count {
|
|
let rowLine = lines[index].trimmingCharacters(in: .whitespaces)
|
|
guard rowLine.contains("|"), !rowLine.isEmpty else { break }
|
|
bodyRows.append(splitTableRow(rowLine))
|
|
index += 1
|
|
}
|
|
html += renderTable(headerCells: headerCells, alignments: alignments, bodyRows: bodyRows)
|
|
continue
|
|
}
|
|
|
|
// Blockquote (escaped ">" is ">")
|
|
if line.hasPrefix("> ") || line == ">" {
|
|
flushParagraph(); flushList()
|
|
var quoteLines: [String] = []
|
|
while index < lines.count {
|
|
let quoteLine = lines[index].trimmingCharacters(in: .whitespaces)
|
|
if quoteLine.hasPrefix("> ") {
|
|
quoteLines.append(String(quoteLine.dropFirst(5)))
|
|
} else if quoteLine == ">" {
|
|
quoteLines.append("")
|
|
} else {
|
|
break
|
|
}
|
|
index += 1
|
|
}
|
|
html += "<blockquote><p>\(renderInline(quoteLines.joined(separator: "<br>\n")))</p></blockquote>\n"
|
|
continue
|
|
}
|
|
|
|
// Unordered list
|
|
if line.hasPrefix("- ") || line.hasPrefix("* ") || line.hasPrefix("+ ") {
|
|
flushParagraph()
|
|
if listTag == "ol" { flushList() }
|
|
listTag = "ul"
|
|
listBuffer.append(String(line.dropFirst(2)))
|
|
index += 1
|
|
continue
|
|
}
|
|
|
|
// Ordered list
|
|
if let text = orderedListMatch(line) {
|
|
flushParagraph()
|
|
if listTag == "ul" { flushList() }
|
|
listTag = "ol"
|
|
listBuffer.append(text)
|
|
index += 1
|
|
continue
|
|
}
|
|
|
|
// Blank line — paragraph/list separator
|
|
if line.isEmpty {
|
|
flushParagraph(); flushList()
|
|
index += 1
|
|
continue
|
|
}
|
|
|
|
// Plain paragraph text
|
|
flushList()
|
|
paragraphLines.append(line)
|
|
index += 1
|
|
}
|
|
|
|
flushParagraph()
|
|
flushList()
|
|
return html
|
|
}
|
|
|
|
nonisolated private static func headerMatch(_ line: String) -> (level: Int, text: String)? {
|
|
var level = 0
|
|
var idx = line.startIndex
|
|
while idx < line.endIndex, line[idx] == "#", level < 6 {
|
|
level += 1
|
|
idx = line.index(after: idx)
|
|
}
|
|
guard level > 0, idx < line.endIndex, line[idx] == " " else { return nil }
|
|
return (level, String(line[line.index(after: idx)...]))
|
|
}
|
|
|
|
nonisolated private static func orderedListMatch(_ line: String) -> String? {
|
|
guard let dotRange = line.range(of: ". ") else { return nil }
|
|
let prefix = line[line.startIndex..<dotRange.lowerBound]
|
|
guard !prefix.isEmpty, prefix.allSatisfy(\.isNumber) else { return nil }
|
|
return String(line[dotRange.upperBound...])
|
|
}
|
|
|
|
nonisolated private static func isHorizontalRule(_ line: String) -> Bool {
|
|
guard line.count >= 3 else { return false }
|
|
return line.allSatisfy { $0 == "-" } || line.allSatisfy { $0 == "*" } || line.allSatisfy { $0 == "_" }
|
|
}
|
|
|
|
/// A GFM table separator row looks like `| --- | :--: | ---: |` — pipe-delimited cells
|
|
/// made up of dashes with optional leading/trailing colons for alignment.
|
|
nonisolated private static func isTableSeparatorRow(_ line: String) -> Bool {
|
|
guard line.contains("-") else { return false }
|
|
let cells = splitTableRow(line)
|
|
guard !cells.isEmpty else { return false }
|
|
return cells.allSatisfy { cell in
|
|
var core = cell.trimmingCharacters(in: .whitespaces)
|
|
guard !core.isEmpty else { return false }
|
|
if core.hasPrefix(":") { core.removeFirst() }
|
|
if core.hasSuffix(":") { core.removeLast() }
|
|
return !core.isEmpty && core.allSatisfy { $0 == "-" }
|
|
}
|
|
}
|
|
|
|
nonisolated private static func splitTableRow(_ line: String) -> [String] {
|
|
var trimmed = line.trimmingCharacters(in: .whitespaces)
|
|
if trimmed.hasPrefix("|") { trimmed.removeFirst() }
|
|
if trimmed.hasSuffix("|") { trimmed.removeLast() }
|
|
return trimmed.components(separatedBy: "|").map { $0.trimmingCharacters(in: .whitespaces) }
|
|
}
|
|
|
|
nonisolated private static func tableAlignments(from separatorLine: String) -> [String] {
|
|
splitTableRow(separatorLine).map { cell in
|
|
let left = cell.hasPrefix(":")
|
|
let right = cell.hasSuffix(":")
|
|
if left && right { return "center" }
|
|
if right { return "right" }
|
|
if left { return "left" }
|
|
return ""
|
|
}
|
|
}
|
|
|
|
nonisolated private static func renderTable(headerCells: [String], alignments: [String], bodyRows: [[String]]) -> String {
|
|
func alignAttr(_ i: Int) -> String {
|
|
guard i < alignments.count, !alignments[i].isEmpty else { return "" }
|
|
return " style=\"text-align:\(alignments[i])\""
|
|
}
|
|
var html = "<table>\n<thead>\n<tr>\n"
|
|
for (i, cell) in headerCells.enumerated() {
|
|
html += "<th\(alignAttr(i))>\(renderInline(cell))</th>\n"
|
|
}
|
|
html += "</tr>\n</thead>\n<tbody>\n"
|
|
for row in bodyRows {
|
|
html += "<tr>\n"
|
|
for (i, cell) in row.enumerated() {
|
|
html += "<td\(alignAttr(i))>\(renderInline(cell))</td>\n"
|
|
}
|
|
html += "</tr>\n"
|
|
}
|
|
html += "</tbody>\n</table>\n"
|
|
return html
|
|
}
|
|
|
|
/// Applies inline markdown (code, links, bold, italic) to an already-HTML-escaped line.
|
|
nonisolated private static func renderInline(_ escapedText: String) -> String {
|
|
var text = escapedText
|
|
var codeSpans: [String] = []
|
|
|
|
text = replacingCaptures(text, pattern: #"`([^`]+?)`"#) { match in
|
|
let token = "\u{E000}\(codeSpans.count)\u{E000}"
|
|
codeSpans.append("<code>\(match)</code>")
|
|
return token
|
|
}
|
|
|
|
text = replacingCaptures(text, pattern: #"\[([^\]]+)\]\(([^)]+)\)"#, groups: 2) { groups in
|
|
"<a href=\"\(groups[1])\">\(groups[0])</a>"
|
|
}
|
|
|
|
text = replacingCaptures(text, pattern: #"\*\*([^*]+?)\*\*"#) { "<strong>\($0)</strong>" }
|
|
text = replacingCaptures(text, pattern: #"__([^_]+?)__"#) { "<strong>\($0)</strong>" }
|
|
text = replacingCaptures(text, pattern: #"\*([^*]+?)\*"#) { "<em>\($0)</em>" }
|
|
|
|
for (i, span) in codeSpans.enumerated() {
|
|
text = text.replacingOccurrences(of: "\u{E000}\(i)\u{E000}", with: span)
|
|
}
|
|
return text
|
|
}
|
|
|
|
nonisolated private static func replacingCaptures(
|
|
_ text: String,
|
|
pattern: String,
|
|
transform: @escaping (String) -> String
|
|
) -> String {
|
|
guard let regex = try? Regex(pattern) else { return text }
|
|
return text.replacing(regex) { match in
|
|
transform(match.output.count > 1 ? String(match.output[1].substring ?? "") : "")
|
|
}
|
|
}
|
|
|
|
nonisolated private static func replacingCaptures(
|
|
_ text: String,
|
|
pattern: String,
|
|
groups: Int,
|
|
transform: @escaping ([String]) -> String
|
|
) -> String {
|
|
guard let regex = try? Regex(pattern) else { return text }
|
|
return text.replacing(regex) { match in
|
|
let captured = (1...groups).map { i in
|
|
match.output.count > i ? String(match.output[i].substring ?? "") : ""
|
|
}
|
|
return transform(captured)
|
|
}
|
|
}
|
|
}
|