feat: export notes as portable Markdown

This commit is contained in:
Valdemar Lorenzen
2026-10-20 13:50:36 +02:00
parent bfdc1ced7d
commit 510cc1df1f
6 changed files with 328 additions and 5 deletions
+3 -1
View File
@@ -143,7 +143,9 @@ Notes uses a standard macOS window with system-inherited appearance and only the
Actions (Command-K) opens the same searchable popup style used throughout Conjure, with **Browse Notes** (Command-P), **New Note** (Command-N), and **Delete Note**. Delete Note asks for confirmation before permanently deleting the currently selected note and its embedded images. Delete is focused by default: Return confirms and Escape cancels; deletion cannot be undone. After deletion, Notes selects a remaining note or creates a fresh blank note if none remain. Save or deletion failures preserve the current note and editor buffer. Command-N creates a note while the editor or either popup is focused. Command-P toggles Browse Notes even while its title-bar controls are covered. Escape or clicking outside closes an open popup and returns focus to the editor without leaving Notes; another Escape dismisses the launcher. Control-J/K navigates only while a popup is open, preserving normal editor commands when it is closed. Actions (Command-K) opens the same searchable popup style used throughout Conjure, with **Browse Notes** (Command-P), **New Note** (Command-N), and **Delete Note**. Delete Note asks for confirmation before permanently deleting the currently selected note and its embedded images. Delete is focused by default: Return confirms and Escape cancels; deletion cannot be undone. After deletion, Notes selects a remaining note or creates a fresh blank note if none remain. Save or deletion failures preserve the current note and editor buffer. Command-N creates a note while the editor or either popup is focused. Command-P toggles Browse Notes even while its title-bar controls are covered. Escape or clicking outside closes an open popup and returns focus to the editor without leaving Notes; another Escape dismisses the launcher. Control-J/K navigates only while a popup is open, preserving normal editor commands when it is closed.
The bottom strip applies Markdown headings, bold, italic, strikethrough, inline code, links, fenced code blocks, quotes, numbered lists, bullet lists, and checklists. Formatting preserves embedded images and supports Undo. List buttons convert existing list types; clicking the current type removes its marker. The formatting strip is always visible. Successful autosaves stay unobtrusive; only save, load, or deletion errors appear below the editor. Notes Actions also includes **Copy as Markdown** and **Export as Markdown…**. Both export the whole note, regardless of the selection, without modifying the editor or RTFD autosaves. Clipboard exports replace images with inline-code filename placeholders such as `<photo.png>`. File exports use a native save dialog: notes without images become a single `.md` file; notes with images become a new folder containing `note.md` and the original image files, linked with relative paths. Unsafe or duplicate image filenames are made safe and unique. Image folders must not already exist; a failed export does not leave a partially written folder. Unsupported or unreadable images report an error rather than being dropped.
The bottom strip applies Markdown headings, bold, italic, strikethrough, inline code, links, fenced code blocks, quotes, numbered lists, bullet lists, and checklists. Formatting preserves embedded images and supports Undo. List buttons convert existing list types; clicking the current type removes its marker. The formatting strip is always visible. Successful autosaves stay unobtrusive; only save, load, deletion, or export errors appear below the editor.
Markdown highlighting and list alignment update in one formatting transaction per edit, so typing does not expose an intermediate unformatted layout. Markdown highlighting and list alignment update in one formatting transaction per edit, so typing does not expose an intermediate unformatted layout.
@@ -37,6 +37,8 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
var browseNotes: (() -> Void)? var browseNotes: (() -> Void)?
var newNote: (() -> Void)? var newNote: (() -> Void)?
var deleteNote: (() -> Void)? var deleteNote: (() -> Void)?
var copyNoteMarkdown: (() -> Void)?
var exportNoteMarkdown: (() -> Void)?
private enum ChoiceID: Equatable { private enum ChoiceID: Equatable {
case language(String) case language(String)
case note(UUID) 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: "OpenAI · gpt-6-luna", symbol: "checkmark", color: SunsetPalette.text),
ActionEntry(title: "Browse Notes", symbol: "list.bullet", color: SunsetPalette.text, commandKey: "P"), 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: "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 = ActionsPanelView()
view.wantsLayer = true view.wantsLayer = true
@@ -259,7 +263,7 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
} }
func showNotesActions(relativeTo anchor: NSView) { 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") searchAccessibilityLabel: "Search notes actions")
} }
@@ -466,7 +470,7 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
} }
private func handleCommandKey(_ key: String) -> Bool { 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 { switch key {
case "p": close(); browseNotes?() case "p": close(); browseNotes?()
case "n": close(); newNote?() case "n": close(); newNote?()
@@ -540,6 +544,8 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
case 12: browseNotes?() case 12: browseNotes?()
case 13: newNote?() case 13: newNote?()
case 14: deleteNote?() case 14: deleteNote?()
case 15: copyNoteMarkdown?()
case 16: exportNoteMarkdown?()
default: break default: break
} }
} }
+48
View File
@@ -1,6 +1,7 @@
import AppKit import AppKit
import ConjureKit import ConjureKit
import Highlighter import Highlighter
import UniformTypeIdentifiers
@MainActor @MainActor
enum NotesAppearance { enum NotesAppearance {
@@ -77,6 +78,8 @@ final class NotesController: NSViewController, NSTextViewDelegate {
actions.newNote = { [weak self] in self?.newNote() } actions.newNote = { [weak self] in self?.newNote() }
actions.browseNotes = { [weak self] in self?.browseNotes() } actions.browseNotes = { [weak self] in self?.browseNotes() }
actions.deleteNote = { [weak self] in self?.deleteNote() } actions.deleteNote = { [weak self] in self?.deleteNote() }
actions.copyNoteMarkdown = { [weak self] in self?.copyAsMarkdown() }
actions.exportNoteMarkdown = { [weak self] in self?.exportAsMarkdown() }
let tools = [ let tools = [
("textformat.size", "Heading"), ("bold", "Bold"), ("italic", "Italic"), ("textformat.size", "Heading"), ("bold", "Bold"), ("italic", "Italic"),
("strikethrough", "Strikethrough"), ("chevron.left.forwardslash.chevron.right", "Inline code"), ("strikethrough", "Strikethrough"), ("chevron.left.forwardslash.chevron.right", "Inline code"),
@@ -534,6 +537,51 @@ final class NotesController: NSViewController, NSTextViewDelegate {
catch { showError(error) } 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() { private func deleteNote() {
guard let window = view.window, window.attachedSheet == nil, let id = selectedID else { return } guard let window = view.window, window.attachedSheet == nil, let id = selectedID else { return }
actions.close(animated: false) actions.close(animated: false)
+135
View File
@@ -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 += "![\(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."
}
}
}
+1 -1
View File
@@ -13,7 +13,7 @@
- Backspace at heading start deletes the preceding newline, not the heading prefix; at document start it does nothing. - Backspace at heading start deletes the preceding newline, not the heading prefix; at document start it does nothing.
- joining onto nonempty prose inherits the preceding paragraph's type: `Text\n## Heading` becomes `TextHeading`; removing an empty line above preserves the heading. - joining onto nonempty prose inherits the preceding paragraph's type: `Text\n## Heading` becomes `TextHeading`; removing an empty line above preserves the heading.
- keep native attributed-text editing, Undo, images, and Markdown serialization; retain RTFD autosaves rather than introducing a full document-model rewrite. - keep native attributed-text editing, Undo, images, and Markdown serialization; retain RTFD autosaves rather than introducing a full document-model rewrite.
- [ ] add whole-note Markdown export actions to the Notes Actions menu: - [x] add whole-note Markdown export actions to the Notes Actions menu:
- Copy as Markdown: replace images with inline-code placeholders such as `<photo.png>`. - Copy as Markdown: replace images with inline-code placeholders such as `<photo.png>`.
- Export as Markdown…: save a single `.md` file without images; with images, create a folder containing the Markdown and image files, using relative links. - Export as Markdown…: save a single `.md` file without images; with images, create a folder containing the Markdown and image files, using relative links.
- exports leave the original note and RTFD autosaves unchanged. - exports leave the original note and RTFD autosaves unchanged.
@@ -0,0 +1,132 @@
import AppKit
import ConjureKit
import Testing
@Test @MainActor
func markdownExportPreservesWholeTextAndSnapshotsBeforeWriting() throws {
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at: directory) }
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: false)
let text = "\n # 日本語 🦊 \r\n**bold** and `code`\n- [ ] task\n\u{fffc}\n"
let source = NSMutableAttributedString(string: text)
source.addAttribute(.attachment, value: NSTextAttachment(), range: NSRange(location: 0, length: 1))
let export = try NoteMarkdownExport(contents: source)
#expect(!export.hasImages)
#expect(export.clipboardText == text)
#expect(source.string == text)
source.mutableString.setString("Edited after export began")
let file = directory.appendingPathComponent("Note.md")
try Data("old file".utf8).write(to: file)
try export.write(to: file)
#expect(try Data(contentsOf: file) == Data(text.utf8))
#expect(try FileManager.default.contentsOfDirectory(atPath: directory.path) == ["Note.md"])
}
@Test @MainActor
func markdownExportWritesOriginalImageBytesAndEscapedLinksWithoutChangingSource() throws {
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at: directory) }
let png = try markdownExportPNG()
let wrapper = FileWrapper(regularFileWithContents: png)
let name = "pixel [x] `1` #%.png"
wrapper.preferredFilename = name
let originalFilename = wrapper.filename
let attachment = NSTextAttachment(fileWrapper: wrapper)
let source = NSMutableAttributedString(string: "\n # 🦊\n")
source.append(NSAttributedString(attachment: attachment))
source.append(NSAttributedString(string: "\n- [ ] todo \n"))
let before = NSAttributedString(attributedString: source)
let export = try NoteMarkdownExport(contents: source)
#expect(export.hasImages)
#expect(export.clipboardText == "\n # 🦊\n``<pixel [x] `1` #%.png>``\n- [ ] todo \n")
#expect(source.isEqual(to: before))
#expect(wrapper.preferredFilename == name)
#expect(wrapper.filename == originalFilename)
#expect(wrapper.regularFileContents == png)
source.mutableString.setString("Changed")
wrapper.preferredFilename = "changed.png"
try export.write(to: directory)
let markdown = "\n # 🦊\n![pixel \\[x\\] \\`1\\` #%.png](pixel%20%5Bx%5D%20%601%60%20%23%25.png)\n- [ ] todo \n"
#expect(try String(contentsOf: directory.appendingPathComponent("note.md"), encoding: .utf8) == markdown)
#expect(try Data(contentsOf: directory.appendingPathComponent(name)) == png)
#expect(Set(try FileManager.default.contentsOfDirectory(atPath: directory.path)) == ["note.md", name])
}
@Test @MainActor
func markdownExportUsesSafeUniqueImageNamesAndReservesMarkdownFile() throws {
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at: directory) }
let png = try markdownExportPNG()
let names = ["same.png", "SAME.png", "../escape.png", "note.md", "CON.png", "bad\\name.png"]
let source = NSMutableAttributedString(string: "")
var wrappers: [FileWrapper] = []
for name in names {
let wrapper = FileWrapper(regularFileWithContents: png)
wrapper.preferredFilename = name
wrappers.append(wrapper)
source.append(NSAttributedString(attachment: NSTextAttachment(fileWrapper: wrapper)))
source.append(NSAttributedString(string: "\n"))
}
let expected = ["same.png", "SAME-2.png", "image-3.png", "note.png", "image-5.png", "image-6.png"]
let export = try NoteMarkdownExport(contents: source)
#expect(export.clipboardText == expected.map { "`<\($0)>`\n" }.joined())
#expect(wrappers.map(\.preferredFilename) == names.map(Optional.some))
try export.write(to: directory)
#expect(try String(contentsOf: directory.appendingPathComponent("note.md"), encoding: .utf8) == expected.map { "![\($0)](\($0))\n" }.joined())
#expect(Set(try FileManager.default.contentsOfDirectory(atPath: directory.path)) == Set(expected + ["note.md"]))
for name in expected {
#expect(try Data(contentsOf: directory.appendingPathComponent(name)) == png)
}
}
@Test @MainActor
func markdownExportPreservesExistingDirectoryAndRemovesFailedStagingDirectory() throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at: root) }
let existing = root.appendingPathComponent("Existing")
try FileManager.default.createDirectory(at: existing, withIntermediateDirectories: true)
let marker = existing.appendingPathComponent("keep.txt")
let saved = Data("User data".utf8)
try saved.write(to: marker)
let wrapper = FileWrapper(regularFileWithContents: try markdownExportPNG())
wrapper.preferredFilename = "pixel.png"
let export = try NoteMarkdownExport(contents: NSAttributedString(attachment: NSTextAttachment(fileWrapper: wrapper)))
#expect(throws: (any Error).self) { try export.write(to: existing) }
#expect(try Data(contentsOf: marker) == saved)
#expect(try FileManager.default.contentsOfDirectory(atPath: existing.path) == ["keep.txt"])
let invalidDestination = root.appendingPathComponent(String(repeating: "x", count: 256))
#expect(throws: (any Error).self) { try export.write(to: invalidDestination) }
#expect(try FileManager.default.contentsOfDirectory(atPath: root.path) == ["Existing"])
}
@Test @MainActor
func markdownExportSupportsNativeImageOnlyAttachmentsAndRejectsUnreadableAttachments() throws {
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at: directory) }
let image = try #require(NSImage(data: markdownExportPNG()))
let attachment = NSTextAttachment()
attachment.attachmentCell = NSTextAttachmentCell(imageCell: image)
#expect(attachment.fileWrapper == nil)
let export = try NoteMarkdownExport(contents: NSAttributedString(attachment: attachment))
#expect(export.clipboardText == "`<image-1.png>`")
try export.write(to: directory)
let data = try Data(contentsOf: directory.appendingPathComponent("image-1.png"))
#expect(NSBitmapImageRep(data: data)?.pixelsWide == 1)
#expect(NSBitmapImageRep(data: data)?.pixelsHigh == 1)
for wrapper in [FileWrapper(regularFileWithContents: Data("not an image".utf8)), FileWrapper(directoryWithFileWrappers: [:])] {
let source = NSAttributedString(attachment: NSTextAttachment(fileWrapper: wrapper))
#expect(throws: (any Error).self) { try NoteMarkdownExport(contents: source) }
#expect(source.string == "\u{fffc}")
}
}
@MainActor
private func markdownExportPNG() throws -> Data {
let bitmap = try #require(NSBitmapImageRep(
bitmapDataPlanes: nil, pixelsWide: 1, pixelsHigh: 1,
bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, isPlanar: false,
colorSpaceName: .deviceRGB, bytesPerRow: 0, bitsPerPixel: 0
))
bitmap.setColor(.red, atX: 0, y: 0)
return try #require(bitmap.representation(using: .png, properties: [:]))
}