spb/zyquo-cloud Public MIT
Native macOS AI chat client for 12 cloud providers — your keys, every cloud model, one beautiful chat.
Swift 97.4%
Shell 1.7%
Makefile 1%
1//2// ConversationExporter.swift3// Zyquo Cloud4//5// Author: Simon-Pierre Boucher6// Mail: contact@spboucher.ai7//8// Conversation export: Markdown text and PDF (rendered via NSPrintOperation-9// compatible attributed text into a PDF context).10//1112import AppKit13import UniformTypeIdentifiers1415enum ConversationExporter {16 // MARK: - Markdown1718 static func markdown(for conversation: Conversation) -> String {19 var lines: [String] = []20 lines.append("# \(conversation.title)")21 lines.append("")22 lines.append("*Exported from Zyquo Cloud — \(conversation.provider.displayName) · \(conversation.modelID)*")23 if let system = conversation.systemPrompt, !system.isEmpty {24 lines.append("")25 lines.append("> **System:** \(system)")26 }27 for message in conversation.messages {28 lines.append("")29 switch message.role {30 case .user:31 lines.append("## 🧑 You")32 case .assistant:33 let model = message.modelID ?? conversation.modelID34 lines.append("## ☁️ Assistant (\(model))")35 case .system:36 lines.append("## System")37 }38 lines.append("")39 if let reasoning = message.reasoning, !reasoning.isEmpty {40 lines.append("<details><summary>Thinking</summary>")41 lines.append("")42 lines.append(reasoning)43 lines.append("")44 lines.append("</details>")45 lines.append("")46 }47 lines.append(message.text)48 if !message.citations.isEmpty {49 lines.append("")50 lines.append("**Sources:**")51 for citation in message.citations {52 lines.append("\(citation.index). [\(citation.title ?? citation.url.absoluteString)](\(citation.url.absoluteString))")53 }54 }55 }56 lines.append("")57 return lines.joined(separator: "\n")58 }5960 // MARK: - PDF6162 static func pdfData(for conversation: Conversation) -> Data? {63 let text = markdown(for: conversation)64 let attributed = NSMutableAttributedString(65 string: text,66 attributes: [67 .font: NSFont.systemFont(ofSize: 11),68 .foregroundColor: NSColor.textColor,69 ]70 )71 let pageRect = CGRect(x: 0, y: 0, width: 612, height: 792) // US Letter72 let inset: CGFloat = 4873 let data = NSMutableData()74 guard let consumer = CGDataConsumer(data: data as CFMutableData),75 var mediaBox = Optional(pageRect),76 let context = CGContext(consumer: consumer, mediaBox: &mediaBox, nil)77 else { return nil }7879 let framesetter = CTFramesetterCreateWithAttributedString(attributed)80 var location = 081 while location < attributed.length {82 context.beginPDFPage(nil)83 let path = CGPath(84 rect: pageRect.insetBy(dx: inset, dy: inset), transform: nil85 )86 let frame = CTFramesetterCreateFrame(87 framesetter, CFRange(location: location, length: 0), path, nil88 )89 CTFrameDraw(frame, context)90 let visible = CTFrameGetVisibleStringRange(frame)91 location += max(visible.length, 1)92 context.endPDFPage()93 }94 context.closePDF()95 return data as Data96 }9798 // MARK: - Save panel99100 @MainActor101 static func presentSavePanel(for conversation: Conversation) {102 let panel = NSSavePanel()103 panel.allowedContentTypes = [104 UTType(filenameExtension: "md") ?? .plainText,105 .pdf,106 ]107 panel.nameFieldStringValue = conversation.title108 .replacingOccurrences(of: "/", with: "-")109 panel.title = "Export Conversation"110 panel.begin { response in111 guard response == .OK, let url = panel.url else { return }112 if url.pathExtension.lowercased() == "pdf" {113 if let data = pdfData(for: conversation) {114 try? data.write(to: url)115 }116 } else {117 let text = markdown(for: conversation)118 try? text.data(using: .utf8)?.write(to: url)119 }120 }121 }122}123