Files
conjure/Sources/ConjureKit/NoteMarkdownExport.swift

137 lines
6.5 KiB
Swift

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<String> = ["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<String>) 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."
}
}
}