// // ExportService.swift // Zyquo Local // // Author: Simon-Pierre Boucher // Mail: contact@spboucher.ai // import AppKit import Foundation import UniformTypeIdentifiers /// Conversation export: Markdown and PDF via save panels. @MainActor enum ExportService { static func markdown(for conversation: Conversation) -> String { var lines: [String] = [] lines.append("# \(conversation.title)") lines.append("") lines.append("_Exported from Zyquo Local · \(conversation.updatedAt.formatted(date: .abbreviated, time: .shortened))_") if let modelID = conversation.modelID { lines.append("_Model: \(modelID)_") } lines.append("") if let system = conversation.systemPrompt, !system.isEmpty { lines.append("> **System:** \(system)") lines.append("") } for message in conversation.messages where message.role != .system { lines.append(message.role == .user ? "## You" : "## Assistant") lines.append("") if let thinking = message.thinking, !thinking.isEmpty { lines.append("
Thinking") lines.append("") lines.append(thinking) lines.append("") lines.append("
") lines.append("") } lines.append(message.content) if let stats = message.stats { lines.append("") lines.append(String( format: "_%.1f tok/s · %d tokens · %.1fs to first token_", stats.tokensPerSecond, stats.generationTokenCount, stats.timeToFirstToken)) } lines.append("") } return lines.joined(separator: "\n") } static func exportMarkdown(_ conversation: Conversation) { let panel = NSSavePanel() panel.allowedContentTypes = [.init(filenameExtension: "md") ?? .plainText] panel.nameFieldStringValue = sanitizedFileName(conversation.title) + ".md" guard panel.runModal() == .OK, let url = panel.url else { return } try? markdown(for: conversation).write(to: url, atomically: true, encoding: .utf8) } static func exportPDF(_ conversation: Conversation) { let panel = NSSavePanel() panel.allowedContentTypes = [.pdf] panel.nameFieldStringValue = sanitizedFileName(conversation.title) + ".pdf" guard panel.runModal() == .OK, let url = panel.url else { return } writePDF(conversation, to: url) } /// Simple paginated PDF from an attributed rendition of the transcript. private static func writePDF(_ conversation: Conversation, to url: URL) { let pageRect = CGRect(x: 0, y: 0, width: 612, height: 792) // US Letter let inset: CGFloat = 54 let contentRect = pageRect.insetBy(dx: inset, dy: inset) let text = NSMutableAttributedString() let titleFont = NSFont.systemFont(ofSize: 18, weight: .semibold) let bodyFont = NSFont.systemFont(ofSize: 11) let roleFont = NSFont.systemFont(ofSize: 11, weight: .semibold) let metaFont = NSFont.systemFont(ofSize: 9) text.append(NSAttributedString( string: conversation.title + "\n", attributes: [.font: titleFont])) text.append(NSAttributedString( string: "Exported from Zyquo Local · \(conversation.updatedAt.formatted())\n\n", attributes: [.font: metaFont, .foregroundColor: NSColor.secondaryLabelColor])) for message in conversation.messages where message.role != .system { text.append(NSAttributedString( string: (message.role == .user ? "You" : "Assistant") + "\n", attributes: [.font: roleFont])) text.append(NSAttributedString( string: message.content + "\n\n", attributes: [.font: bodyFont])) } var mediaBox = pageRect guard let consumer = CGDataConsumer(url: url as CFURL), let context = CGContext(consumer: consumer, mediaBox: &mediaBox, nil) else { return } let framesetter = CTFramesetterCreateWithAttributedString(text) var location = 0 while location < text.length { context.beginPDFPage(nil) let path = CGPath(rect: contentRect, transform: nil) let frame = CTFramesetterCreateFrame( framesetter, CFRange(location: location, length: 0), path, nil) CTFrameDraw(frame, context) let visible = CTFrameGetVisibleStringRange(frame) location += max(visible.length, 1) context.endPDFPage() } context.closePDF() } private static func sanitizedFileName(_ name: String) -> String { let invalid = CharacterSet(charactersIn: "/\\:?%*|\"<>") let cleaned = name.components(separatedBy: invalid).joined(separator: "-") return cleaned.isEmpty ? "Conversation" : cleaned } }