SPB Git

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%
4.3 KB · 123 lines swift
Raw Blame History
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