diff --git a/oAI/Resources/oAI.help/Contents/Resources/en.lproj/index.html b/oAI/Resources/oAI.help/Contents/Resources/en.lproj/index.html index 1f9bca2..6cd21e3 100644 --- a/oAI/Resources/oAI.help/Contents/Resources/en.lproj/index.html +++ b/oAI/Resources/oAI.help/Contents/Resources/en.lproj/index.html @@ -260,8 +260,8 @@
/delete <name>
Delete a saved conversation
-
/export md|json
-
Export conversation as Markdown or JSON
+
/export md|html|pdf|json
+
Export conversation as Markdown, HTML, PDF, or JSON

MCP Commands

@@ -567,10 +567,12 @@

Exporting Conversations

-

Export to Markdown or JSON format:

+

Export to Markdown, HTML, PDF, or JSON format:

/export md + /export html + /export pdf /export json -

Files are saved to your Downloads folder.

+

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.

diff --git a/oAI/Services/ConversationExportService.swift b/oAI/Services/ConversationExportService.swift new file mode 100644 index 0000000..cbe84a3 --- /dev/null +++ b/oAI/Services/ConversationExportService.swift @@ -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 +// 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) + } + } +} diff --git a/oAI/ViewModels/ChatViewModel.swift b/oAI/ViewModels/ChatViewModel.swift index 5847a93..2aa6d84 100644 --- a/oAI/ViewModels/ChatViewModel.swift +++ b/oAI/ViewModels/ChatViewModel.swift @@ -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 ") + showSystemMessage("Usage: /export md|html|pdf|json ") } 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") } } diff --git a/oAI/Views/Main/InputBar.swift b/oAI/Views/Main/InputBar.swift index 641afd6..71d71af 100644 --- a/oAI/Views/Main/InputBar.swift +++ b/oAI/Views/Main/InputBar.swift @@ -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"), diff --git a/oAI/Views/Screens/ConversationListView.swift b/oAI/Views/Screens/ConversationListView.swift index de668a2..d1ce2ee 100644 --- a/oAI/Views/Screens/ConversationListView.swift +++ b/oAI/Views/Screens/ConversationListView.swift @@ -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) } } diff --git a/oAI/Views/Screens/HelpView.swift b/oAI/Views/Screens/HelpView.swift index 9063dd9..27cf139 100644 --- a/oAI/Views/Screens/HelpView.swift +++ b/oAI/Views/Screens/HelpView.swift @@ -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: [ diff --git a/oAI/oAIApp.swift b/oAI/oAIApp.swift index 132864d..79c7035 100644 --- a/oAI/oAIApp.swift +++ b/oAI/oAIApp.swift @@ -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 ───────────────────────────────────────────────── diff --git a/oAITests/ConversationExportServiceTests.swift b/oAITests/ConversationExportServiceTests.swift new file mode 100644 index 0000000..1f815e2 --- /dev/null +++ b/oAITests/ConversationExportServiceTests.swift @@ -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: " & more")] + let html = ConversationExportService.html(name: "Test", messages: messages) + #expect(!html.contains("