feat: export notes as portable Markdown
This commit is contained in:
@@ -37,6 +37,8 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
|
||||
var browseNotes: (() -> Void)?
|
||||
var newNote: (() -> Void)?
|
||||
var deleteNote: (() -> Void)?
|
||||
var copyNoteMarkdown: (() -> Void)?
|
||||
var exportNoteMarkdown: (() -> Void)?
|
||||
private enum ChoiceID: Equatable {
|
||||
case language(String)
|
||||
case note(UUID)
|
||||
@@ -86,7 +88,9 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
|
||||
ActionEntry(title: "OpenAI · gpt-6-luna", symbol: "checkmark", color: SunsetPalette.text),
|
||||
ActionEntry(title: "Browse Notes", symbol: "list.bullet", color: SunsetPalette.text, commandKey: "P"),
|
||||
ActionEntry(title: "New Note", symbol: "square.and.pencil", color: SunsetPalette.text, commandKey: "N"),
|
||||
ActionEntry(title: "Delete Note", symbol: "trash", color: .systemRed)
|
||||
ActionEntry(title: "Delete Note", symbol: "trash", color: .systemRed),
|
||||
ActionEntry(title: "Copy as Markdown", symbol: "doc.on.doc", color: SunsetPalette.text),
|
||||
ActionEntry(title: "Export as Markdown…", symbol: "square.and.arrow.up", color: SunsetPalette.text)
|
||||
]
|
||||
view = ActionsPanelView()
|
||||
view.wantsLayer = true
|
||||
@@ -259,7 +263,7 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
|
||||
}
|
||||
|
||||
func showNotesActions(relativeTo anchor: NSView) {
|
||||
show(title: "Notes", relativeTo: anchor, available: [12, 13, 14],
|
||||
show(title: "Notes", relativeTo: anchor, available: [12, 13, 14, 15, 16],
|
||||
searchAccessibilityLabel: "Search notes actions")
|
||||
}
|
||||
|
||||
@@ -466,7 +470,7 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
|
||||
}
|
||||
|
||||
private func handleCommandKey(_ key: String) -> Bool {
|
||||
guard isShown, availableEntries == [12, 13, 14] else { return false }
|
||||
guard isShown, availableEntries == [12, 13, 14, 15, 16] else { return false }
|
||||
switch key {
|
||||
case "p": close(); browseNotes?()
|
||||
case "n": close(); newNote?()
|
||||
@@ -540,6 +544,8 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
|
||||
case 12: browseNotes?()
|
||||
case 13: newNote?()
|
||||
case 14: deleteNote?()
|
||||
case 15: copyNoteMarkdown?()
|
||||
case 16: exportNoteMarkdown?()
|
||||
default: break
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import AppKit
|
||||
import ConjureKit
|
||||
import Highlighter
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
@MainActor
|
||||
enum NotesAppearance {
|
||||
@@ -77,6 +78,8 @@ final class NotesController: NSViewController, NSTextViewDelegate {
|
||||
actions.newNote = { [weak self] in self?.newNote() }
|
||||
actions.browseNotes = { [weak self] in self?.browseNotes() }
|
||||
actions.deleteNote = { [weak self] in self?.deleteNote() }
|
||||
actions.copyNoteMarkdown = { [weak self] in self?.copyAsMarkdown() }
|
||||
actions.exportNoteMarkdown = { [weak self] in self?.exportAsMarkdown() }
|
||||
let tools = [
|
||||
("textformat.size", "Heading"), ("bold", "Bold"), ("italic", "Italic"),
|
||||
("strikethrough", "Strikethrough"), ("chevron.left.forwardslash.chevron.right", "Inline code"),
|
||||
@@ -534,6 +537,51 @@ final class NotesController: NSViewController, NSTextViewDelegate {
|
||||
catch { showError(error) }
|
||||
}
|
||||
|
||||
private func copyAsMarkdown() {
|
||||
actions.close(animated: false)
|
||||
do {
|
||||
let export = try NoteMarkdownExport(contents: editor.markdownContents)
|
||||
let clipboard = NSPasteboard.general
|
||||
clipboard.clearContents()
|
||||
guard clipboard.setString(export.clipboardText, forType: .string) else {
|
||||
throw CocoaError(.fileWriteUnknown)
|
||||
}
|
||||
} catch { showExportError(error) }
|
||||
}
|
||||
|
||||
private func exportAsMarkdown() {
|
||||
guard let window = view.window, window.attachedSheet == nil else { return }
|
||||
actions.close(animated: false)
|
||||
do {
|
||||
let export = try NoteMarkdownExport(contents: editor.markdownContents)
|
||||
let panel = NSSavePanel()
|
||||
panel.title = "Export as Markdown"
|
||||
panel.prompt = "Export"
|
||||
panel.canCreateDirectories = true
|
||||
panel.nameFieldStringValue = export.hasImages ? "Note" : "Note.md"
|
||||
if export.hasImages {
|
||||
panel.message = "Create a new folder containing note.md and its images. Choose a folder name that does not already exist."
|
||||
} else {
|
||||
panel.allowedContentTypes = [UTType(filenameExtension: "md") ?? .plainText]
|
||||
}
|
||||
panel.beginSheetModal(for: window) { [weak self] response in
|
||||
guard let self else { return }
|
||||
defer { self.focusEditor() }
|
||||
guard response == .OK, let url = panel.url else { return }
|
||||
do { try export.write(to: url) }
|
||||
catch { self.showExportError(error) }
|
||||
}
|
||||
} catch { showExportError(error) }
|
||||
}
|
||||
|
||||
private func showExportError(_ error: Error) {
|
||||
status.isHidden = false
|
||||
status.stringValue = "Couldn’t export note: \(error.localizedDescription)"
|
||||
status.toolTip = status.stringValue
|
||||
status.textColor = .systemRed
|
||||
}
|
||||
|
||||
|
||||
private func deleteNote() {
|
||||
guard let window = view.window, window.attachedSheet == nil, let id = selectedID else { return }
|
||||
actions.close(animated: false)
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
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 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 += ")"
|
||||
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."
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user