Add HTML and PDF conversation export
Top item on the roadmap ranking from 2026-07-27 — multi-modal export alongside the existing Markdown/JSON. New ConversationExportService consolidates the two previously-duplicated Markdown builders (ChatViewModel and ConversationListView had separate copies of the same **User**/**Assistant** + --- format) and adds: - A hand-rolled Markdown->HTML renderer scoped to what actually shows up in chat messages (headers, bold/italic, inline code, fenced code blocks, lists, blockquotes, links, horizontal rules) rather than full CommonMark/GFM — no existing markdown-to-HTML utility existed in the codebase, and swift-markdown-ui is SwiftUI-view-only with no HTML-string export API. Content is HTML-escaped before any markdown substitution so example code containing "<div>" etc renders as visible text, not live markup. - PDF via an offscreen WKWebView loading that same HTML and calling the official createPDF(configuration:) API (macOS 11+) — no new project/framework linkage needed, WebKit is a system framework. Wired into every place Markdown export already existed: File menu (Export as HTML.../PDF...), /export slash command (now md|html|pdf|json), and a new Export submenu (Markdown/HTML/PDF) on each conversation row's context menu in the advanced conversation list, replacing the old single-format swipe-only export. Help docs and InputBar autocomplete updated to match.
This commit is contained in:
@@ -260,8 +260,8 @@
|
|||||||
<dt>/delete <name></dt>
|
<dt>/delete <name></dt>
|
||||||
<dd>Delete a saved conversation</dd>
|
<dd>Delete a saved conversation</dd>
|
||||||
|
|
||||||
<dt>/export md|json</dt>
|
<dt>/export md|html|pdf|json</dt>
|
||||||
<dd>Export conversation as Markdown or JSON</dd>
|
<dd>Export conversation as Markdown, HTML, PDF, or JSON</dd>
|
||||||
</dl>
|
</dl>
|
||||||
|
|
||||||
<h3>MCP Commands</h3>
|
<h3>MCP Commands</h3>
|
||||||
@@ -567,10 +567,12 @@
|
|||||||
</ol>
|
</ol>
|
||||||
|
|
||||||
<h3>Exporting Conversations</h3>
|
<h3>Exporting Conversations</h3>
|
||||||
<p>Export to Markdown or JSON format:</p>
|
<p>Export to Markdown, HTML, PDF, or JSON format:</p>
|
||||||
<code class="command">/export md</code>
|
<code class="command">/export md</code>
|
||||||
|
<code class="command">/export html</code>
|
||||||
|
<code class="command">/export pdf</code>
|
||||||
<code class="command">/export json</code>
|
<code class="command">/export json</code>
|
||||||
<p class="note">Files are saved to your Downloads folder.</p>
|
<p class="note">Files are saved to your Downloads folder. HTML and PDF export are also available from File → Export as HTML…/PDF…, and per-conversation from the Export submenu in the conversation list's context menu.</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Git Sync -->
|
<!-- Git Sync -->
|
||||||
|
|||||||
@@ -0,0 +1,364 @@
|
|||||||
|
//
|
||||||
|
// 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 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 = """
|
||||||
|
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<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, 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: "<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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 == "_" }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -727,7 +727,7 @@ Don't narrate future actions ("Let me...") - just use the tools.
|
|||||||
let filename = args.count >= 2 ? args[1] : "conversation.\(format)"
|
let filename = args.count >= 2 ? args[1] : "conversation.\(format)"
|
||||||
exportConversation(format: format, filename: filename)
|
exportConversation(format: format, filename: filename)
|
||||||
} else {
|
} else {
|
||||||
showSystemMessage("Usage: /export md|json <filename>")
|
showSystemMessage("Usage: /export md|html|pdf|json <filename>")
|
||||||
}
|
}
|
||||||
|
|
||||||
case "/info":
|
case "/info":
|
||||||
@@ -1769,13 +1769,29 @@ Don't narrate future actions ("Let me...") - just use the tools.
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if format == "pdf" {
|
||||||
|
let name = currentConversationName ?? "conversation"
|
||||||
|
Task { @MainActor in
|
||||||
|
do {
|
||||||
|
let data = try await ConversationExportService.pdfData(name: name, messages: chatMessages)
|
||||||
|
if let url = ConversationExportService.writeToDownloads(data, filename: filename) {
|
||||||
|
showSystemMessage("Exported to \(url.path)")
|
||||||
|
} else {
|
||||||
|
showSystemMessage("Export failed: could not write file")
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
showSystemMessage("Export failed: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
let content: String
|
let content: String
|
||||||
switch format {
|
switch format {
|
||||||
case "md", "markdown":
|
case "md", "markdown":
|
||||||
content = chatMessages.map { msg in
|
content = ConversationExportService.markdown(messages: chatMessages)
|
||||||
let header = msg.role == .user ? "**User**" : "**Assistant**"
|
case "html":
|
||||||
return "\(header)\n\n\(msg.content)"
|
content = ConversationExportService.html(name: currentConversationName ?? "conversation", messages: chatMessages)
|
||||||
}.joined(separator: "\n\n---\n\n")
|
|
||||||
case "json":
|
case "json":
|
||||||
let dicts = chatMessages.map { msg -> [String: String] in
|
let dicts = chatMessages.map { msg -> [String: String] in
|
||||||
["role": msg.role.rawValue, "content": msg.content]
|
["role": msg.role.rawValue, "content": msg.content]
|
||||||
@@ -1788,20 +1804,14 @@ Don't narrate future actions ("Let me...") - just use the tools.
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
showSystemMessage("Unsupported format: \(format). Use md or json.")
|
showSystemMessage("Unsupported format: \(format). Use md, html, pdf, or json.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write to Downloads folder
|
if let url = ConversationExportService.writeToDownloads(content, filename: filename) {
|
||||||
let downloads = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first
|
showSystemMessage("Exported to \(url.path)")
|
||||||
?? FileManager.default.temporaryDirectory
|
} else {
|
||||||
let fileURL = downloads.appendingPathComponent(filename)
|
showSystemMessage("Export failed: could not write file")
|
||||||
|
|
||||||
do {
|
|
||||||
try content.write(to: fileURL, atomically: true, encoding: .utf8)
|
|
||||||
showSystemMessage("Exported to \(fileURL.path)")
|
|
||||||
} catch {
|
|
||||||
showSystemMessage("Export failed: \(error.localizedDescription)")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ struct InputBar: View {
|
|||||||
"/memory on", "/memory off", "/online on", "/online off",
|
"/memory on", "/memory off", "/online on", "/online off",
|
||||||
"/mcp on", "/mcp off", "/mcp status", "/mcp list",
|
"/mcp on", "/mcp off", "/mcp status", "/mcp list",
|
||||||
"/mcp write on", "/mcp write off",
|
"/mcp write on", "/mcp write off",
|
||||||
"/export md", "/export json",
|
"/export md", "/export html", "/export pdf", "/export json",
|
||||||
]
|
]
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -291,6 +291,8 @@ struct CommandSuggestionsView: View {
|
|||||||
("/load", "Load conversation"),
|
("/load", "Load conversation"),
|
||||||
("/list", "List saved conversations"),
|
("/list", "List saved conversations"),
|
||||||
("/export md", "Export as Markdown"),
|
("/export md", "Export as Markdown"),
|
||||||
|
("/export html", "Export as HTML"),
|
||||||
|
("/export pdf", "Export as PDF"),
|
||||||
("/export json", "Export as JSON"),
|
("/export json", "Export as JSON"),
|
||||||
("/info", "Show model information"),
|
("/info", "Show model information"),
|
||||||
("/credits", "Check account credits"),
|
("/credits", "Check account credits"),
|
||||||
|
|||||||
@@ -400,6 +400,25 @@ struct ConversationListView: View {
|
|||||||
} label: {
|
} label: {
|
||||||
Label("Move to Folder", systemImage: "folder")
|
Label("Move to Folder", systemImage: "folder")
|
||||||
}
|
}
|
||||||
|
Menu {
|
||||||
|
Button {
|
||||||
|
exportConversation(conversation, format: "md")
|
||||||
|
} label: {
|
||||||
|
Label("Markdown", systemImage: "doc.text")
|
||||||
|
}
|
||||||
|
Button {
|
||||||
|
exportConversation(conversation, format: "html")
|
||||||
|
} label: {
|
||||||
|
Label("HTML", systemImage: "chevron.left.forwardslash.chevron.right")
|
||||||
|
}
|
||||||
|
Button {
|
||||||
|
exportConversation(conversation, format: "pdf")
|
||||||
|
} label: {
|
||||||
|
Label("PDF", systemImage: "doc.richtext")
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Label("Export", systemImage: "square.and.arrow.up")
|
||||||
|
}
|
||||||
Button {
|
Button {
|
||||||
renameConversation(conversation)
|
renameConversation(conversation)
|
||||||
} label: {
|
} label: {
|
||||||
@@ -662,21 +681,34 @@ struct ConversationListView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func exportConversation(_ conversation: Conversation) {
|
private func exportConversation(_ conversation: Conversation, format: String = "md") {
|
||||||
guard let (_, loadedMessages) = try? DatabaseService.shared.loadConversation(id: conversation.id),
|
guard let (_, loadedMessages) = try? DatabaseService.shared.loadConversation(id: conversation.id),
|
||||||
!loadedMessages.isEmpty else {
|
!loadedMessages.isEmpty else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
let content = loadedMessages.map { msg in
|
let baseName = conversation.name.replacingOccurrences(of: " ", with: "_")
|
||||||
let header = msg.role == .user ? "**User**" : "**Assistant**"
|
|
||||||
return "\(header)\n\n\(msg.content)"
|
|
||||||
}.joined(separator: "\n\n---\n\n")
|
|
||||||
|
|
||||||
let downloads = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first
|
if format == "pdf" {
|
||||||
?? FileManager.default.temporaryDirectory
|
Task { @MainActor in
|
||||||
let filename = conversation.name.replacingOccurrences(of: " ", with: "_") + ".md"
|
guard let data = try? await ConversationExportService.pdfData(name: conversation.name, messages: loadedMessages) else {
|
||||||
let fileURL = downloads.appendingPathComponent(filename)
|
return
|
||||||
try? content.write(to: fileURL, atomically: true, encoding: .utf8)
|
}
|
||||||
|
_ = ConversationExportService.writeToDownloads(data, filename: baseName + ".pdf")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let content: String
|
||||||
|
let filename: String
|
||||||
|
switch format {
|
||||||
|
case "html":
|
||||||
|
content = ConversationExportService.html(name: conversation.name, messages: loadedMessages)
|
||||||
|
filename = baseName + ".html"
|
||||||
|
default:
|
||||||
|
content = ConversationExportService.markdown(messages: loadedMessages)
|
||||||
|
filename = baseName + ".md"
|
||||||
|
}
|
||||||
|
_ = ConversationExportService.writeToDownloads(content, filename: filename)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -134,10 +134,10 @@ private let helpCategories: [CommandCategory] = [
|
|||||||
examples: ["/delete old-chat", "/delete test"]
|
examples: ["/delete old-chat", "/delete test"]
|
||||||
),
|
),
|
||||||
CommandDetail(
|
CommandDetail(
|
||||||
command: "/export md|json",
|
command: "/export md|html|pdf|json",
|
||||||
brief: "Export conversation",
|
brief: "Export conversation",
|
||||||
detail: "Exports the current conversation to a file. Supports Markdown (.md) and JSON (.json) formats. Optionally provide a custom filename.",
|
detail: "Exports the current conversation to a file. Supports Markdown (.md), HTML (.html), PDF (.pdf), and JSON (.json) formats. Optionally provide a custom filename.",
|
||||||
examples: ["/export md", "/export json", "/export md my-chat.md"]
|
examples: ["/export md", "/export html", "/export pdf", "/export json", "/export md my-chat.md"]
|
||||||
),
|
),
|
||||||
]),
|
]),
|
||||||
CommandCategory(name: "MCP (File Access)", icon: "folder.badge.gearshape", commands: [
|
CommandCategory(name: "MCP (File Access)", icon: "folder.badge.gearshape", commands: [
|
||||||
|
|||||||
@@ -124,6 +124,20 @@ struct oAIApp: App {
|
|||||||
chatViewModel.exportConversation(format: "md", filename: "\(safe).md")
|
chatViewModel.exportConversation(format: "md", filename: "\(safe).md")
|
||||||
}
|
}
|
||||||
.disabled(chatViewModel.messages.filter { $0.role != .system }.isEmpty)
|
.disabled(chatViewModel.messages.filter { $0.role != .system }.isEmpty)
|
||||||
|
|
||||||
|
Button("Export as HTML…") {
|
||||||
|
let name = chatViewModel.currentConversationName ?? "conversation"
|
||||||
|
let safe = name.components(separatedBy: .whitespacesAndNewlines).joined(separator: "-")
|
||||||
|
chatViewModel.exportConversation(format: "html", filename: "\(safe).html")
|
||||||
|
}
|
||||||
|
.disabled(chatViewModel.messages.filter { $0.role != .system }.isEmpty)
|
||||||
|
|
||||||
|
Button("Export as PDF…") {
|
||||||
|
let name = chatViewModel.currentConversationName ?? "conversation"
|
||||||
|
let safe = name.components(separatedBy: .whitespacesAndNewlines).joined(separator: "-")
|
||||||
|
chatViewModel.exportConversation(format: "pdf", filename: "\(safe).pdf")
|
||||||
|
}
|
||||||
|
.disabled(chatViewModel.messages.filter { $0.role != .system }.isEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Chat menu ─────────────────────────────────────────────────
|
// ── Chat menu ─────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
//
|
||||||
|
// ConversationExportServiceTests.swift
|
||||||
|
// oAITests
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||||
|
// Copyright (C) 2026 Rune Olsen
|
||||||
|
|
||||||
|
import Testing
|
||||||
|
import Foundation
|
||||||
|
@testable import oAI
|
||||||
|
|
||||||
|
@Suite("ConversationExportService")
|
||||||
|
struct ConversationExportServiceTests {
|
||||||
|
|
||||||
|
// MARK: - Markdown
|
||||||
|
|
||||||
|
@Test("Markdown format matches the established User/Assistant + --- convention")
|
||||||
|
func markdownFormat() {
|
||||||
|
let messages = [
|
||||||
|
Message(role: .user, content: "hi"),
|
||||||
|
Message(role: .assistant, content: "hello there"),
|
||||||
|
]
|
||||||
|
let result = ConversationExportService.markdown(messages: messages)
|
||||||
|
#expect(result == "**User**\n\nhi\n\n---\n\n**Assistant**\n\nhello there")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - HTML escaping
|
||||||
|
|
||||||
|
@Test("HTML-escapes angle brackets and ampersands instead of rendering them as markup")
|
||||||
|
func escapesHTMLSpecialCharacters() {
|
||||||
|
let messages = [Message(role: .assistant, content: "<script>alert('x')</script> & more")]
|
||||||
|
let html = ConversationExportService.html(name: "Test", messages: messages)
|
||||||
|
#expect(!html.contains("<script>alert"))
|
||||||
|
#expect(html.contains("<script>"))
|
||||||
|
#expect(html.contains("&"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("HTML document includes the conversation name as title and heading")
|
||||||
|
func includesConversationName() {
|
||||||
|
let messages = [Message(role: .user, content: "hi")]
|
||||||
|
let html = ConversationExportService.html(name: "My <Chat>", messages: messages)
|
||||||
|
#expect(html.contains("<title>My <Chat></title>"))
|
||||||
|
#expect(html.contains("<h1>My <Chat></h1>"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Block-level markdown rendering
|
||||||
|
|
||||||
|
@Test("Renders a header line as an <h1> tag")
|
||||||
|
func rendersHeader() {
|
||||||
|
let body = ConversationExportService.renderMarkdownBody("# Title")
|
||||||
|
#expect(body.contains("<h1>Title</h1>"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Renders a fenced code block with a language class, without applying inline formatting inside it")
|
||||||
|
func rendersFencedCodeBlock() {
|
||||||
|
let body = ConversationExportService.renderMarkdownBody("```swift\nlet x = **not bold**\n```")
|
||||||
|
#expect(body.contains("<pre><code class=\"language-swift\">"))
|
||||||
|
#expect(body.contains("let x = **not bold**"))
|
||||||
|
#expect(!body.contains("<strong>not bold</strong>"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Renders an unordered list as <ul><li> items")
|
||||||
|
func rendersUnorderedList() {
|
||||||
|
let body = ConversationExportService.renderMarkdownBody("- one\n- two")
|
||||||
|
#expect(body.contains("<ul>"))
|
||||||
|
#expect(body.contains("<li>one</li>"))
|
||||||
|
#expect(body.contains("<li>two</li>"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Renders an ordered list as <ol><li> items")
|
||||||
|
func rendersOrderedList() {
|
||||||
|
let body = ConversationExportService.renderMarkdownBody("1. first\n2. second")
|
||||||
|
#expect(body.contains("<ol>"))
|
||||||
|
#expect(body.contains("<li>first</li>"))
|
||||||
|
#expect(body.contains("<li>second</li>"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Renders a horizontal rule as <hr>")
|
||||||
|
func rendersHorizontalRule() {
|
||||||
|
let body = ConversationExportService.renderMarkdownBody("above\n\n---\n\nbelow")
|
||||||
|
#expect(body.contains("<hr>"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Renders a plain paragraph wrapped in <p>")
|
||||||
|
func rendersParagraph() {
|
||||||
|
let body = ConversationExportService.renderMarkdownBody("just some text")
|
||||||
|
#expect(body.contains("<p>just some text</p>"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Renders inline bold, italic, and inline code within a paragraph")
|
||||||
|
func rendersInlineFormatting() {
|
||||||
|
let body = ConversationExportService.renderMarkdownBody("**bold** and *italic* and `code`")
|
||||||
|
#expect(body.contains("<strong>bold</strong>"))
|
||||||
|
#expect(body.contains("<em>italic</em>"))
|
||||||
|
#expect(body.contains("<code>code</code>"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Does not apply bold/italic formatting to asterisks inside an inline code span")
|
||||||
|
func inlineCodeProtectsItsContentFromOtherInlineFormatting() {
|
||||||
|
let body = ConversationExportService.renderMarkdownBody("`**not bold**`")
|
||||||
|
#expect(body.contains("<code>**not bold**</code>"))
|
||||||
|
#expect(!body.contains("<strong>"))
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user