// // 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 // for // the full license text. For commercial licensing, contact Rune // Olsen via . 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 """
\(roleLabel)
\(rendered)
""" }.joined(separator: "\n") return """ \(htmlEscape(name))

\(htmlEscape(name))

\(body) """ } nonisolated private static let css = """ 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; } """ // 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)) 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? func load(_ html: String, in webView: WKWebView) async throws { try await withCheckedThrowingContinuation { (cont: CheckedContinuation) 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, paragraphs. Not full CommonMark/GFM — no tables.) 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: "
\n") html += "

\(renderInline(joined))

\n" paragraphLines = [] } func flushList() { guard let tag = listTag, !listBuffer.isEmpty else { return } html += "<\(tag)>\n" for item in listBuffer { html += "
  • \(renderInline(item))
  • \n" } html += "\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 += "
    \(codeLines.joined(separator: "\n"))
    \n" if index < lines.count { index += 1 } // skip closing ``` continue } // Headers if let header = headerMatch(line) { flushParagraph(); flushList() html += "\(renderInline(header.text))\n" index += 1 continue } // Horizontal rule if isHorizontalRule(line) { flushParagraph(); flushList() html += "
    \n" index += 1 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 += "

    \(renderInline(quoteLines.joined(separator: "
    \n")))

    \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.. Bool { guard line.count >= 3 else { return false } return line.allSatisfy { $0 == "-" } || line.allSatisfy { $0 == "*" } || line.allSatisfy { $0 == "_" } } /// 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("\(match)") return token } text = replacingCaptures(text, pattern: #"\[([^\]]+)\]\(([^)]+)\)"#, groups: 2) { groups in "\(groups[0])" } text = replacingCaptures(text, pattern: #"\*\*([^*]+?)\*\*"#) { "\($0)" } text = replacingCaptures(text, pattern: #"__([^_]+?)__"#) { "\($0)" } text = replacingCaptures(text, pattern: #"\*([^*]+?)\*"#) { "\($0)" } 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) } } }