import AppKit import ImageIO import UniformTypeIdentifiers @MainActor public struct NoteMarkdownExport { public let clipboardText: String public var hasImages: Bool { !images.isEmpty } private let markdown: String private let images: [(name: String, data: Data)] public init(contents: NSAttributedString) throws { let contents = NoteDocument.markdown(contents) let source = contents.string as NSString var markdown = "" var clipboard = "" var images: [(name: String, data: Data)] = [] var usedNames: Set = ["note.md"] var cursor = 0 while cursor < source.length { let range = source.range(of: "\u{fffc}", options: [], range: NSRange(location: cursor, length: source.length - cursor)) let end = range.location == NSNotFound ? source.length : range.location let text = source.substring(with: NSRange(location: cursor, length: end - cursor)) markdown += text clipboard += text guard range.location != NSNotFound else { break } if let value = contents.attribute(.attachment, at: range.location, effectiveRange: nil) { guard let attachment = value as? NSTextAttachment else { throw ExportError.unreadableAttachment } let (data, type) = try Self.imageData(attachment) let originalName = attachment.fileWrapper?.preferredFilename ?? attachment.fileWrapper?.filename let name = try Self.filename(originalName, type: type, index: images.count + 1, used: &usedNames) var alt = "" var ticks = 0 var longestTicks = 0 for character in name { if "\\[]`*_<>!&".contains(character) { alt += "\\" } alt.append(character) ticks = character == "`" ? ticks + 1 : 0 longestTicks = max(longestTicks, ticks) } guard let target = name.addingPercentEncoding(withAllowedCharacters: Self.linkCharacters) else { throw ExportError.unreadableAttachment } markdown += "![\(alt)](\(target))" let fence = String(repeating: "`", count: longestTicks + 1) clipboard += "\(fence)<\(name)>\(fence)" images.append((name, data)) } else { markdown += "\u{fffc}" clipboard += "\u{fffc}" } cursor = NSMaxRange(range) } self.markdown = markdown clipboardText = clipboard self.images = images } public func write(to url: URL) throws { guard hasImages else { try markdown.write(to: url, atomically: true, encoding: .utf8) return } let manager = FileManager.default guard !manager.fileExists(atPath: url.path) else { throw CocoaError(.fileWriteFileExists, userInfo: [NSFilePathErrorKey: url.path]) } let staging = url.deletingLastPathComponent().appendingPathComponent(".conjure-export-\(UUID().uuidString)", isDirectory: true) try manager.createDirectory(at: staging, withIntermediateDirectories: false) defer { try? manager.removeItem(at: staging) } try markdown.write(to: staging.appendingPathComponent("note.md"), atomically: false, encoding: .utf8) for image in images { try image.data.write(to: staging.appendingPathComponent(image.name)) } try manager.moveItem(at: staging, to: url) } private static func imageData(_ attachment: NSTextAttachment) throws -> (Data, UTType) { if let wrapper = attachment.fileWrapper { guard wrapper.isRegularFile, let data = wrapper.regularFileContents, let source = CGImageSourceCreateWithData(data as CFData, nil), let identifier = CGImageSourceGetType(source), let type = UTType(identifier as String), type.conforms(to: .image), CGImageSourceCreateImageAtIndex(source, 0, nil) != nil else { throw ExportError.unreadableAttachment } return (data, type) } guard let image = attachment.image ?? (attachment.attachmentCell as? NSTextAttachmentCell)?.image, let cgImage = image.cgImage(forProposedRect: nil, context: nil, hints: nil), let png = NSBitmapImageRep(cgImage: cgImage).representation(using: .png, properties: [:]) else { throw ExportError.unreadableAttachment } return (png, .png) } private static func filename(_ original: String?, type: UTType, index: Int, used: inout Set) throws -> String { guard let detectedExtension = type.preferredFilenameExtension else { throw ExportError.unreadableAttachment } var stem = "image-\(index)" var ext = detectedExtension if let original, isSafeFilename(original) { let path = original as NSString stem = path.deletingPathExtension if UTType(filenameExtension: path.pathExtension) == type { ext = path.pathExtension } } var name = "\(stem).\(ext)" var suffix = 2 while !used.insert(name.folding(options: .caseInsensitive, locale: Self.filenameLocale)).inserted { name = "\(stem)-\(suffix).\(ext)" suffix += 1 } return name } private static let linkCharacters = CharacterSet(charactersIn: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~") private static let reservedNames = Set(["con", "prn", "aux", "nul"] + (1...9).flatMap { ["com\($0)", "lpt\($0)"] }) private static let filenameLocale = Locale(identifier: "en_US_POSIX") private static func isSafeFilename(_ name: String) -> Bool { guard !name.isEmpty, name.utf8.count <= 180, !name.hasPrefix("."), !name.hasSuffix("."), name == name.trimmingCharacters(in: .whitespacesAndNewlines), name.rangeOfCharacter(from: .controlCharacters) == nil, !name.contains("/"), !name.contains("\\"), !name.contains(":") else { return false } let stem = (name as NSString).deletingPathExtension.lowercased() return !reservedNames.contains(stem) } private enum ExportError: LocalizedError { case unreadableAttachment var errorDescription: String? { "This note contains an unsupported or unreadable image attachment. Nothing was exported." } } }