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>
|
||||
<dd>Delete a saved conversation</dd>
|
||||
|
||||
<dt>/export md|json</dt>
|
||||
<dd>Export conversation as Markdown or JSON</dd>
|
||||
<dt>/export md|html|pdf|json</dt>
|
||||
<dd>Export conversation as Markdown, HTML, PDF, or JSON</dd>
|
||||
</dl>
|
||||
|
||||
<h3>MCP Commands</h3>
|
||||
@@ -567,10 +567,12 @@
|
||||
</ol>
|
||||
|
||||
<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 html</code>
|
||||
<code class="command">/export pdf</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>
|
||||
|
||||
<!-- 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)"
|
||||
exportConversation(format: format, filename: filename)
|
||||
} else {
|
||||
showSystemMessage("Usage: /export md|json <filename>")
|
||||
showSystemMessage("Usage: /export md|html|pdf|json <filename>")
|
||||
}
|
||||
|
||||
case "/info":
|
||||
@@ -1769,13 +1769,29 @@ Don't narrate future actions ("Let me...") - just use the tools.
|
||||
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
|
||||
switch format {
|
||||
case "md", "markdown":
|
||||
content = chatMessages.map { msg in
|
||||
let header = msg.role == .user ? "**User**" : "**Assistant**"
|
||||
return "\(header)\n\n\(msg.content)"
|
||||
}.joined(separator: "\n\n---\n\n")
|
||||
content = ConversationExportService.markdown(messages: chatMessages)
|
||||
case "html":
|
||||
content = ConversationExportService.html(name: currentConversationName ?? "conversation", messages: chatMessages)
|
||||
case "json":
|
||||
let dicts = chatMessages.map { msg -> [String: String] in
|
||||
["role": msg.role.rawValue, "content": msg.content]
|
||||
@@ -1788,20 +1804,14 @@ Don't narrate future actions ("Let me...") - just use the tools.
|
||||
return
|
||||
}
|
||||
default:
|
||||
showSystemMessage("Unsupported format: \(format). Use md or json.")
|
||||
showSystemMessage("Unsupported format: \(format). Use md, html, pdf, or json.")
|
||||
return
|
||||
}
|
||||
|
||||
// Write to Downloads folder
|
||||
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)
|
||||
showSystemMessage("Exported to \(fileURL.path)")
|
||||
} catch {
|
||||
showSystemMessage("Export failed: \(error.localizedDescription)")
|
||||
if let url = ConversationExportService.writeToDownloads(content, filename: filename) {
|
||||
showSystemMessage("Exported to \(url.path)")
|
||||
} else {
|
||||
showSystemMessage("Export failed: could not write file")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -54,7 +54,7 @@ struct InputBar: View {
|
||||
"/memory on", "/memory off", "/online on", "/online off",
|
||||
"/mcp on", "/mcp off", "/mcp status", "/mcp list",
|
||||
"/mcp write on", "/mcp write off",
|
||||
"/export md", "/export json",
|
||||
"/export md", "/export html", "/export pdf", "/export json",
|
||||
]
|
||||
|
||||
var body: some View {
|
||||
@@ -291,6 +291,8 @@ struct CommandSuggestionsView: View {
|
||||
("/load", "Load conversation"),
|
||||
("/list", "List saved conversations"),
|
||||
("/export md", "Export as Markdown"),
|
||||
("/export html", "Export as HTML"),
|
||||
("/export pdf", "Export as PDF"),
|
||||
("/export json", "Export as JSON"),
|
||||
("/info", "Show model information"),
|
||||
("/credits", "Check account credits"),
|
||||
|
||||
@@ -400,6 +400,25 @@ struct ConversationListView: View {
|
||||
} label: {
|
||||
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 {
|
||||
renameConversation(conversation)
|
||||
} 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),
|
||||
!loadedMessages.isEmpty else {
|
||||
return
|
||||
}
|
||||
let content = loadedMessages.map { msg in
|
||||
let header = msg.role == .user ? "**User**" : "**Assistant**"
|
||||
return "\(header)\n\n\(msg.content)"
|
||||
}.joined(separator: "\n\n---\n\n")
|
||||
let baseName = conversation.name.replacingOccurrences(of: " ", with: "_")
|
||||
|
||||
let downloads = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first
|
||||
?? FileManager.default.temporaryDirectory
|
||||
let filename = conversation.name.replacingOccurrences(of: " ", with: "_") + ".md"
|
||||
let fileURL = downloads.appendingPathComponent(filename)
|
||||
try? content.write(to: fileURL, atomically: true, encoding: .utf8)
|
||||
if format == "pdf" {
|
||||
Task { @MainActor in
|
||||
guard let data = try? await ConversationExportService.pdfData(name: conversation.name, messages: loadedMessages) else {
|
||||
return
|
||||
}
|
||||
_ = 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"]
|
||||
),
|
||||
CommandDetail(
|
||||
command: "/export md|json",
|
||||
command: "/export md|html|pdf|json",
|
||||
brief: "Export conversation",
|
||||
detail: "Exports the current conversation to a file. Supports Markdown (.md) and JSON (.json) formats. Optionally provide a custom filename.",
|
||||
examples: ["/export md", "/export json", "/export md my-chat.md"]
|
||||
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 html", "/export pdf", "/export json", "/export md my-chat.md"]
|
||||
),
|
||||
]),
|
||||
CommandCategory(name: "MCP (File Access)", icon: "folder.badge.gearshape", commands: [
|
||||
|
||||
@@ -124,6 +124,20 @@ struct oAIApp: App {
|
||||
chatViewModel.exportConversation(format: "md", filename: "\(safe).md")
|
||||
}
|
||||
.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 ─────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user