Files
oai-swift/oAI/Services/ConversationExportService.swift
rune c3abc5a748 Update remaining oai.pm references to confab.no
Covers the per-file license-header comment (~80 Swift files) plus
the contact/website links in README.md, PRIVACY.md, and SECURITY.md.
2026-08-03 08:44:35 +02:00

541 lines
23 KiB
Swift

//
// ConversationExportService.swift
// Confab
//
// 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 Confab.
//
// Confab 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 Confab 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://confab.no>.
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 private static let css = """
:root { color-scheme: light dark; }
html { -webkit-text-size-adjust: 100%; text-size-adjust: 100%; }
::-webkit-scrollbar { display: none; }
body { font-family: -apple-system, BlinkMacSystemFont, "Helvetica Neue", sans-serif; \
font-size: 14px; color: #1a1a1a; background: #ffffff; max-width: 820px; margin: 40px auto; \
padding: 0 24px; line-height: 1.5; }
h1 { font-size: 22px; border-bottom: 1px solid #ddd; padding-bottom: 12px; }
.message { margin: 20px 0; padding: 12px 16px; border-radius: 8px; border-left: 4px solid transparent; \
break-inside: avoid; page-break-inside: avoid; }
.message.user { background: #f5f7fa; border-left-color: #4a90d9; }
.message.assistant { background: #fafafa; border-left-color: #8a8a8a; }
.role { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; \
color: #666; margin-bottom: 8px; }
.message p { margin: 8px 0; }
.message h1 { font-size: 19px; margin: 12px 0 6px; }
.message h2 { font-size: 17px; margin: 12px 0 6px; }
.message h3 { font-size: 15.5px; margin: 12px 0 6px; }
.message h4, .message h5, .message h6 { font-size: 14px; 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; }
}
"""
// PDF is baked at export time — it can't respond to prefers-color-scheme live like an
// HTML file opened in a browser can, and empirically the print pipeline ignores that media
// query entirely regardless (confirmed: identical CSS printed light even with the webview
// forced into dark appearance). So PDF gets its own always-dark stylesheet, applied
// unconditionally rather than behind a media query. print-color-adjust: exact is required
// too — browsers/WebKit's print pipeline strips background colors by default to save ink
// unless told otherwise; without it every background here silently prints white.
nonisolated private static let pdfCss = """
* { -webkit-print-color-adjust: exact; print-color-adjust: exact; color-adjust: exact; }
:root { color-scheme: dark; }
html { -webkit-text-size-adjust: 100%; text-size-adjust: 100%; }
::-webkit-scrollbar { display: none; }
body { font-family: -apple-system, BlinkMacSystemFont, "Helvetica Neue", sans-serif; \
font-size: 14px; color: #e8e8e8; background: #1c1c1e; max-width: 820px; margin: 40px auto; \
padding: 0 24px; line-height: 1.5; }
h1 { font-size: 22px; border-bottom: 1px solid #3a3a3c; padding-bottom: 12px; }
.message { margin: 20px 0; padding: 12px 16px; border-radius: 8px; border-left: 4px solid transparent; \
break-inside: avoid; page-break-inside: avoid; }
.message.user { background: #24303d; border-left-color: #5aa2f0; }
.message.assistant { background: #262626; border-left-color: #9a9a9a; }
.role { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; \
color: #a8a8a8; margin-bottom: 8px; }
.message p { margin: 8px 0; }
.message h1 { font-size: 19px; margin: 12px 0 6px; }
.message h2 { font-size: 17px; margin: 12px 0 6px; }
.message h3 { font-size: 15.5px; margin: 12px 0 6px; }
.message h4, .message h5, .message h6 { font-size: 14px; 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 #555; color: #bbb; }
.message code { font-family: "SF Mono", Menlo, Consolas, monospace; font-size: 0.9em; \
background: #2e2e2e; color: #e0e0e0; padding: 1px 5px; border-radius: 4px; }
.message pre { background: #111; 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 #3a3a3c; margin: 16px 0; }
.message a { color: #6fb1f0; }
.message table { border-collapse: collapse; margin: 10px 0; width: 100%; }
.message th, .message td { border: 1px solid #3a3a3c; padding: 6px 10px; text-align: left; }
.message th { background: #2a2a2c; font-weight: 600; }
"""
nonisolated static func html(name: String, messages: [Message], forceDarkCSS: Bool = false) -> 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">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>\(htmlEscape(name))</title>
<style>\(forceDarkCSS ? pdfCss : css)</style>
</head>
<body>
<h1>\(htmlEscape(name))</h1>
\(body)
</body>
</html>
"""
}
// MARK: - PDF
enum PDFError: LocalizedError {
case generationFailed
var errorDescription: String? { "Failed to generate PDF" }
}
/// Uses WKWebView's real print pipeline (NSPrintOperation), not createPDF(). Confirmed
/// empirically by comparing against a normal reference PDF: createPDF() never paginates —
/// it captures the webview's frame verbatim, or auto-grows to ONE continuous page matching
/// full content height for overflow (a single page many thousands of points tall for a long
/// conversation). That's a fundamentally different, non-standard document shape from any
/// normal PDF, which is what actually made ordinarily-sized text read as "huge" — there was
/// no page of a familiar size to judge it against. Printing through NSPrintOperation with a
/// real A4 paper size gives genuine multi-page pagination, matching what any other app's
/// "export/print to PDF" produces.
static func pdfData(name: String, messages: [Message]) async throws -> Data {
let htmlString = html(name: name, messages: messages, forceDarkCSS: true)
let webView = WKWebView(frame: NSRect(x: 0, y: 0, width: 850, height: 1100))
webView.pageZoom = 1.0
let delegate = PDFLoadDelegate()
webView.navigationDelegate = delegate
try await delegate.load(htmlString, in: webView)
let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString + ".pdf")
defer { try? FileManager.default.removeItem(at: tempURL) }
let printInfo = NSPrintInfo()
printInfo.paperSize = NSSize(width: 595, height: 842) // A4
printInfo.topMargin = 36
printInfo.bottomMargin = 36
printInfo.leftMargin = 36
printInfo.rightMargin = 36
printInfo.horizontalPagination = .fit
printInfo.jobDisposition = .save
printInfo.dictionary()[NSPrintInfo.AttributeKey.jobSavingURL] = tempURL
let printOp = webView.printOperation(with: printInfo)
printOp.showsPrintPanel = false
printOp.showsProgressPanel = false
// NSPrintOperation.run() blocks synchronously, so it's dispatched off the main actor
// to avoid freezing the UI while a long conversation paginates.
let success = await withCheckedContinuation { (continuation: CheckedContinuation<Bool, Never>) in
DispatchQueue.global(qos: .userInitiated).async {
continuation.resume(returning: printOp.run())
}
}
guard success, let data = try? Data(contentsOf: tempURL) else {
throw PDFError.generationFailed
}
return data
}
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: "&amp;")
result = result.replacingOccurrences(of: "<", with: "&lt;")
result = result.replacingOccurrences(of: ">", with: "&gt;")
result = result.replacingOccurrences(of: "\"", with: "&quot;")
result = result.replacingOccurrences(of: "'", with: "&#39;")
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 "&gt;")
if line.hasPrefix("&gt; ") || line == "&gt;" {
flushParagraph(); flushList()
var quoteLines: [String] = []
while index < lines.count {
let quoteLine = lines[index].trimmingCharacters(in: .whitespaces)
if quoteLine.hasPrefix("&gt; ") {
quoteLines.append(String(quoteLine.dropFirst(5)))
} else if quoteLine == "&gt;" {
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)
}
}
}