import AppKit import CryptoKit import Markdown public enum NoteAttributes { public static let block = NSAttributedString.Key("ConjureNote.block") public static let headingLevel = NSAttributedString.Key("ConjureNote.headingLevel") public static let depth = NSAttributedString.Key("ConjureNote.depth") public static let number = NSAttributedString.Key("ConjureNote.number") public static let checked = NSAttributedString.Key("ConjureNote.checked") public static let language = NSAttributedString.Key("ConjureNote.language") public static let inlineCode = NSAttributedString.Key("ConjureNote.inlineCode") public static let bold = NSAttributedString.Key("ConjureNote.bold") public static let italic = NSAttributedString.Key("ConjureNote.italic") } /// The attributed string is the document; Markdown is only an import/export format. @MainActor public enum NoteDocument { static let metadataFilename = "ConjureNote.json" public static func importMarkdown(_ source: NSAttributedString) -> NSAttributedString { MarkdownImporter(source).contents() } public static func markdown(_ contents: NSAttributedString) -> NSAttributedString { let result = NSMutableAttributedString(string: "") let text = contents.string as NSString var cursor = 0 while cursor < contents.length { let range = text.paragraphRange(for: NSRange(location: cursor, length: 0)) let attributes = contents.attributes(at: cursor, effectiveRange: nil) let block = attributes[NoteAttributes.block] as? String if block == "code" { let language = attributes[NoteAttributes.language] as? String ?? "" var end = NSMaxRange(range) while end < contents.length, contents.attribute(NoteAttributes.block, at: end, effectiveRange: nil) as? String == "code", (contents.attribute(NoteAttributes.language, at: end, effectiveRange: nil) as? String ?? "") == language { end = NSMaxRange(text.paragraphRange(for: NSRange(location: end, length: 0))) } let code = contents.attributedSubstring(from: NSRange(location: cursor, length: end - cursor)) let fence = String(repeating: "`", count: max(3, longestRun(of: "`", in: code.string) + 1)) result.append(NSAttributedString(string: fence + language.replacingOccurrences(of: "\n", with: " ") + "\n")) result.append(code) if !code.string.hasSuffix("\n") { result.append(NSAttributedString(string: "\n")) } result.append(NSAttributedString(string: fence)) if code.string.hasSuffix("\n") || end < contents.length { result.append(NSAttributedString(string: "\n")) } cursor = end continue } let depth = max(0, attributes[NoteAttributes.depth] as? Int ?? 0) var prefix = "" switch block { case "heading": prefix = String(repeating: "#", count: min(6, max(1, attributes[NoteAttributes.headingLevel] as? Int ?? 1))) + " " case "quote": prefix = String(repeating: "> ", count: depth + 1) case "bullet": prefix = String(repeating: " ", count: depth) + "- " case "ordered": prefix = String(repeating: " ", count: depth) + "\(attributes[NoteAttributes.number] as? Int ?? 1). " case "task": prefix = String(repeating: " ", count: depth) + ((attributes[NoteAttributes.checked] as? Bool ?? false) ? "- [x] " : "- [ ] ") default: break } result.append(NSAttributedString(string: prefix)) var bodyEnd = NSMaxRange(range) while bodyEnd > range.location, [10, 13, 0x2028, 0x2029].contains(text.character(at: bodyEnd - 1)) { bodyEnd -= 1 } appendInline(contents, range: NSRange(location: cursor, length: bodyEnd - cursor), to: result) if bodyEnd < NSMaxRange(range) { result.append(contents.attributedSubstring(from: NSRange(location: bodyEnd, length: NSMaxRange(range) - bodyEnd))) } cursor = NSMaxRange(range) } return NSAttributedString(attributedString: result) } public static func fileWrapper(_ contents: NSAttributedString) throws -> FileWrapper { let wrapper = try contents.fileWrapper( from: NSRange(location: 0, length: contents.length), documentAttributes: [.documentType: NSAttributedString.DocumentType.rtfd] ) guard wrapper.fileWrappers?[metadataFilename] == nil else { throw DocumentError.reservedMetadataFilename } // Bind metadata to the text AppKit actually stores (RTF normalizes line endings). guard let stored = NSAttributedString(rtfdFileWrapper: wrapper, documentAttributes: nil) else { throw DocumentError.corruptMetadata } guard stored.length == contents.length, stored.string == contents.string else { throw DocumentError.textChangedDuringEncoding } let metadata = Metadata(version: 1, length: stored.length, digest: digest(stored.string), runs: runs(in: contents)) let json = FileWrapper(regularFileWithContents: try JSONEncoder().encode(metadata)) json.preferredFilename = metadataFilename wrapper.addFileWrapper(json) return wrapper } public static func contents(from wrapper: FileWrapper) throws -> NSAttributedString { guard wrapper.isDirectory else { throw DocumentError.corruptMetadata } try validate(wrapper) guard let file = wrapper.fileWrappers?[metadataFilename], file.isRegularFile, let data = file.regularFileContents else { throw DocumentError.corruptMetadata } let metadata: Metadata do { metadata = try JSONDecoder().decode(Metadata.self, from: data) } catch { throw DocumentError.corruptMetadata } guard metadata.version == 1 else { throw DocumentError.unsupportedVersion(metadata.version) } guard let decoded = NSAttributedString(rtfdFileWrapper: wrapper, documentAttributes: nil), metadata.length == decoded.length, metadata.digest == digest(decoded.string) else { throw DocumentError.corruptMetadata } let result = NSMutableAttributedString(attributedString: decoded) var end = 0 for run in metadata.runs { guard run.location == end, run.length > 0, run.location >= 0, run.location <= result.length, run.length <= result.length - run.location, run.values.isValid else { throw DocumentError.corruptMetadata } result.addAttributes(run.values.attributes, range: NSRange(location: run.location, length: run.length)) end += run.length } guard end == result.length else { throw DocumentError.corruptMetadata } return NSAttributedString(attributedString: result) } private static func validate(_ wrapper: FileWrapper) throws { guard !wrapper.isSymbolicLink else { throw DocumentError.corruptMetadata } if wrapper.isDirectory { guard let children = wrapper.fileWrappers else { throw DocumentError.corruptMetadata } for (name, child) in children { guard !name.isEmpty, name != ".", name != "..", !name.contains("/"), !name.contains("\\"), child.preferredFilename.map({ !$0.isEmpty && $0 != "." && $0 != ".." && !$0.contains("/") && !$0.contains("\\") }) ?? true else { throw DocumentError.corruptMetadata } try validate(child) } } else if !wrapper.isRegularFile { throw DocumentError.corruptMetadata } } public enum DocumentError: LocalizedError { case corruptMetadata case unsupportedVersion(Int) case textChangedDuringEncoding case reservedMetadataFilename public var errorDescription: String? { switch self { case .corruptMetadata: "This note's structured metadata is missing, corrupt, or does not match its text. The original note was not changed." case .unsupportedVersion(let version): "This note uses unsupported structured metadata version \(version). The original note was not changed." case .textChangedDuringEncoding: "The note could not be saved without changing its text. The original note was not changed." case .reservedMetadataFilename: "An attachment uses the reserved filename ConjureNote.json. Rename the attachment before saving. The original note was not changed." } } } private static func digest(_ string: String) -> String { SHA256.hash(data: Data(string.utf8)).map { String(format: "%02x", $0) }.joined() } private struct Metadata: Codable { let version: Int let length: Int let digest: String let runs: [Run] } private struct Run: Codable { let location: Int let length: Int let values: Values } private struct Values: Codable { var block: String? var headingLevel: Int? var depth: Int? var number: Int? var checked: Bool? var language: String? var inlineCode: Bool? var bold: Bool? var italic: Bool? var underline: Int? var strike: Int? var link: String? init(_ attributes: [NSAttributedString.Key: Any]) { block = attributes[NoteAttributes.block] as? String headingLevel = attributes[NoteAttributes.headingLevel] as? Int depth = attributes[NoteAttributes.depth] as? Int number = attributes[NoteAttributes.number] as? Int checked = attributes[NoteAttributes.checked] as? Bool language = attributes[NoteAttributes.language] as? String inlineCode = attributes[NoteAttributes.inlineCode] as? Bool bold = attributes[NoteAttributes.bold] as? Bool italic = attributes[NoteAttributes.italic] as? Bool underline = attributes[.underlineStyle] as? Int strike = attributes[.strikethroughStyle] as? Int link = (attributes[.link] as? URL)?.absoluteString ?? attributes[.link] as? String } var isValid: Bool { (block == nil || ["heading", "quote", "bullet", "ordered", "task", "code"].contains(block!)) && (headingLevel == nil || (1...6).contains(headingLevel!)) && (depth == nil || depth! >= 0) && (number == nil || number! >= 0) } var attributes: [NSAttributedString.Key: Any] { var result: [NSAttributedString.Key: Any] = [:] result[NoteAttributes.block] = block result[NoteAttributes.headingLevel] = headingLevel result[NoteAttributes.depth] = depth result[NoteAttributes.number] = number result[NoteAttributes.checked] = checked result[NoteAttributes.language] = language result[NoteAttributes.inlineCode] = inlineCode result[NoteAttributes.bold] = bold result[NoteAttributes.italic] = italic result[.underlineStyle] = underline result[.strikethroughStyle] = strike result[.link] = link return result } } private static func runs(in contents: NSAttributedString) -> [Run] { var result: [Run] = [] contents.enumerateAttributes(in: NSRange(location: 0, length: contents.length)) { attributes, range, _ in result.append(Run(location: range.location, length: range.length, values: Values(attributes))) } return result } private static func longestRun(of character: Character, in text: String) -> Int { var longest = 0 var count = 0 for value in text { count = value == character ? count + 1 : 0 longest = max(longest, count) } return longest } private static func appendInline(_ contents: NSAttributedString, range: NSRange, to output: NSMutableAttributedString) { let semantic = NSMutableAttributedString(string: (contents.string as NSString).substring(with: range)) contents.enumerateAttributes(in: range) { attributes, run, _ in let traits = (attributes[.font] as? NSFont).map { NSFontManager.shared.traits(of: $0) } ?? [] var marks: [NSAttributedString.Key: Any] = [:] if attributes[NoteAttributes.bold] as? Bool ?? traits.contains(.boldFontMask) { marks[NoteAttributes.bold] = true } if attributes[NoteAttributes.italic] as? Bool ?? traits.contains(.italicFontMask) { marks[NoteAttributes.italic] = true } if attributes[NoteAttributes.inlineCode] as? Bool ?? false { marks[NoteAttributes.inlineCode] = true } if (attributes[.underlineStyle] as? Int ?? 0) != 0 { marks[.underlineStyle] = 1 } if (attributes[.strikethroughStyle] as? Int ?? 0) != 0 { marks[.strikethroughStyle] = 1 } marks[.link] = (attributes[.link] as? URL)?.absoluteString ?? attributes[.link] as? String semantic.addAttributes(marks, range: NSRange(location: run.location - range.location, length: run.length)) } // Markdown emphasis cannot open/close against whitespace. Trim each semantic // span, not each display-font/color run; underline and links retain spaces. let string = semantic.string as NSString for key in [NoteAttributes.bold, NoteAttributes.italic, .strikethroughStyle] { var edges: [NSRange] = [] semantic.enumerateAttribute(key, in: NSRange(location: 0, length: semantic.length)) { value, span, _ in guard value != nil else { return } var start = span.location var end = NSMaxRange(span) while start < end, string.character(at: start) == 32 || string.character(at: start) == 9 { start += 1 } while end > start, string.character(at: end - 1) == 32 || string.character(at: end - 1) == 9 { end -= 1 } if start > span.location { edges.append(NSRange(location: span.location, length: start - span.location)) } if end < NSMaxRange(span) { edges.append(NSRange(location: end, length: NSMaxRange(span) - end)) } } for edge in edges { semantic.removeAttribute(key, range: edge) } } var active: [(open: String, close: String)] = [] semantic.enumerateAttributes(in: NSRange(location: 0, length: semantic.length)) { attributes, run, _ in let value = contents.attributedSubstring(from: NSRange(location: range.location + run.location, length: run.length)) let text = value.string let code = attributes[NoteAttributes.inlineCode] as? Bool ?? false var desired: [(open: String, close: String)] = [] if let link = attributes[.link] as? String { let destination = link.replacingOccurrences(of: "\\", with: "%5C").replacingOccurrences(of: " ", with: "%20").replacingOccurrences(of: "<", with: "%3C").replacingOccurrences(of: ">", with: "%3E").replacingOccurrences(of: "\n", with: "%0A") desired.append(("[", "](<\(destination)>)")) } for (enabled, delimiter) in [(attributes[NoteAttributes.bold] as? Bool ?? false, "**"), (attributes[NoteAttributes.italic] as? Bool ?? false, "*"), ((attributes[.underlineStyle] as? Int ?? 0) != 0, ""), ((attributes[.strikethroughStyle] as? Int ?? 0) != 0, "~~")] where enabled { desired.append((delimiter, delimiter == "" ? "" : delimiter)) } var common = 0 while common < min(active.count, desired.count), active[common].open == desired[common].open, active[common].close == desired[common].close { common += 1 } for mark in active.dropFirst(common).reversed() { output.append(NSAttributedString(string: mark.close)) } for mark in desired.dropFirst(common) { output.append(NSAttributedString(string: mark.open)) } active = desired if code { let fence = String(repeating: "`", count: max(1, longestRun(of: "`", in: text) + 1)) let pad = text.hasPrefix("`") || text.hasSuffix("`") || (text.hasPrefix(" ") && text.hasSuffix(" ") && !text.allSatisfy({ $0 == " " })) output.append(NSAttributedString(string: fence + (pad ? " " : ""))) output.append(value) output.append(NSAttributedString(string: (pad ? " " : "") + fence)) } else { var index = 0 for character in text { let length = String(character).utf16.count if length == 1, "\\`*_{}[]<>()#+-.!|>~&".contains(character) { output.append(NSAttributedString(string: "\\")) } output.append(value.attributedSubstring(from: NSRange(location: index, length: length))) index += length } } } for mark in active.reversed() { output.append(NSAttributedString(string: mark.close)) } } } @MainActor private final class MarkdownImporter { private let source: NSAttributedString private let text: NSString private let bytes: [UInt8] private let lineStarts: [Int] private let utf16Offsets: [Int] private let result: NSMutableAttributedString private var deletions: [NSRange] = [] private var replacements: [(NSRange, NSAttributedString)] = [] init(_ source: NSAttributedString) { self.source = source text = source.string as NSString bytes = Array(source.string.utf8) var starts = [0] for (index, byte) in bytes.enumerated() where byte == 10 { starts.append(index + 1) } lineStarts = starts var offsets = Array(repeating: 0, count: bytes.count + 1) var byteOffset = 0 var utf16Offset = 0 for scalar in source.string.unicodeScalars { for index in 0.. NSAttributedString { let document = Document(parsing: source.string) visit(document) var merged: [NSRange] = [] for range in deletions.filter({ $0.length > 0 }).sorted(by: { $0.location < $1.location }) { if let last = merged.last, NSMaxRange(last) >= range.location { merged[merged.count - 1] = NSUnionRange(last, range) } else { merged.append(range) } } var edits = replacements for range in merged where !replacements.contains(where: { NSIntersectionRange($0.0, range).length > 0 }) { edits.append((range, NSAttributedString(string: ""))) } for (range, value) in edits.sorted(by: { $0.0.location > $1.0.location }) { result.replaceCharacters(in: range, with: value) } return NSAttributedString(attributedString: result) } private func offset(_ location: SourceLocation) -> Int { guard location.line > 0, location.line <= lineStarts.count else { return text.length } let byte = min(bytes.count, lineStarts[location.line - 1] + max(0, location.column - 1)) return utf16Offsets[byte] } private func range(_ markup: any Markup) -> NSRange? { guard let range = markup.range else { return nil } let start = offset(range.lowerBound) let end = offset(range.upperBound) guard end >= start else { return nil } return NSRange(location: start, length: end - start) } private func mark(_ range: NSRange, _ attributes: [NSAttributedString.Key: Any], paragraph: Bool = false) { let target = paragraph ? text.paragraphRange(for: range) : range result.addAttributes(attributes, range: target) } private func inlineContents(_ node: any Markup, sourceRange: NSRange) -> NSAttributedString { var cursor = sourceRange.location var attachments: [NSTextAttachment] = [] source.enumerateAttribute(.attachment, in: sourceRange) { value, _, _ in if let attachment = value as? NSTextAttachment { attachments.append(attachment) } } var attachmentIndex = 0 func literal(_ string: String) -> NSMutableAttributedString { let remaining = NSRange(location: cursor, length: max(0, NSMaxRange(sourceRange) - cursor)) let match = text.range(of: string, options: [], range: remaining) let value: NSMutableAttributedString if !string.isEmpty, match.location != NSNotFound { value = NSMutableAttributedString(attributedString: result.attributedSubstring(from: match)) cursor = NSMaxRange(match) } else { let attributes = cursor < result.length ? result.attributes(at: cursor, effectiveRange: nil) : [:] value = NSMutableAttributedString(string: string, attributes: attributes) } let characters = string as NSString for index in 0.. NSMutableAttributedString { let output = NSMutableAttributedString(string: "") var underline: [(offset: Int, literal: String)] = [] for child in container.children { if let html = child as? InlineHTML { if html.rawHTML.lowercased() == "" { underline.append((output.length, html.rawHTML)); continue } if html.rawHTML.lowercased() == "", let start = underline.popLast() { output.addAttribute(.underlineStyle, value: NSUnderlineStyle.single.rawValue, range: NSRange(location: start.offset, length: output.length - start.offset)) continue } } output.append(render(child)) } // Unmatched HTML is unsupported literal content, not a disappearing delimiter. for start in underline.reversed() { output.insert(NSAttributedString(string: start.literal), at: start.offset) } return output } func render(_ child: any Markup) -> NSMutableAttributedString { switch child { case let value as Text: return literal(value.string) case let value as InlineCode: let output = literal(value.code) output.addAttribute(NoteAttributes.inlineCode, value: true, range: NSRange(location: 0, length: output.length)) return output case is SoftBreak, is LineBreak: let remaining = NSRange(location: cursor, length: max(0, NSMaxRange(sourceRange) - cursor)) let newline = text.rangeOfCharacter(from: .newlines, options: [], range: remaining) let output = NSMutableAttributedString(string: "") if newline.location != NSNotFound { var start = newline.location while start > cursor, text.character(at: start - 1) == 32 || text.character(at: start - 1) == 9 { start -= 1 } if start < newline.location { output.append(result.attributedSubstring(from: NSRange(location: start, length: newline.location - start))) } cursor = newline.location } output.append(literal("\n")) return output case let html as InlineHTML: return literal(html.rawHTML) case is Strong, is Emphasis, is Strikethrough, is Link: let output = children(child) let range = NSRange(location: 0, length: output.length) if child is Strong { output.addAttribute(NoteAttributes.bold, value: true, range: range) } if child is Emphasis { output.addAttribute(NoteAttributes.italic, value: true, range: range) } if child is Strikethrough { output.addAttribute(.strikethroughStyle, value: NSUnderlineStyle.single.rawValue, range: range) } if let link = child as? Link, let destination = link.destination { output.addAttribute(.link, value: destination, range: range) } return output default: return literal(child.format()) } } let output = children(node) // The parser deliberately omits terminal Markdown hard-break whitespace. // It is still native note text and must survive migration/import. var suffix = NSMaxRange(sourceRange) while suffix > sourceRange.location, text.character(at: suffix - 1) == 32 || text.character(at: suffix - 1) == 9 { suffix -= 1 } let rendered = output.string as NSString var renderedSuffix = rendered.length while renderedSuffix > 0, rendered.character(at: renderedSuffix - 1) == 32 || rendered.character(at: renderedSuffix - 1) == 9 { renderedSuffix -= 1 } let missing = NSMaxRange(sourceRange) - suffix - (rendered.length - renderedSuffix) if missing > 0 { output.append(result.attributedSubstring(from: NSRange(location: NSMaxRange(sourceRange) - missing, length: missing))) } return output } private func visit(_ node: any Markup, listDepth: Int = 0) { guard let whole = range(node) else { for child in node.children { visit(child, listDepth: listDepth) } return } switch node { case let heading as Heading: mark(whole, [NoteAttributes.block: "heading", NoteAttributes.headingLevel: heading.level], paragraph: true) replacements.append((whole, inlineContents(node, sourceRange: whole))) return case let code as CodeBlock: let full = text.paragraphRange(for: whole) var value = code.code if NSMaxRange(full) == text.length, !source.string.hasSuffix("\n"), value.hasSuffix("\n") { value.removeLast() } // An empty code paragraph still needs a real character on which semantics can live. if value.isEmpty { value = "\n" } var attributes: [NSAttributedString.Key: Any] = [NoteAttributes.block: "code"] if let language = code.language { attributes[NoteAttributes.language] = language } let valueContents = NSMutableAttributedString(string: value, attributes: whole.location < result.length ? result.attributes(at: whole.location, effectiveRange: nil) : [:]) valueContents.addAttributes(attributes, range: NSRange(location: 0, length: valueContents.length)) var attachmentIndex = 0 var attachments: [NSTextAttachment] = [] source.enumerateAttribute(.attachment, in: full) { value, _, _ in if let attachment = value as? NSTextAttachment { attachments.append(attachment) } } let characters = value as NSString for index in 0.. cursor { deletions.append(NSRange(location: cursor, length: end - cursor)) } cursor = NSMaxRange(line) } case is OrderedList, is UnorderedList: let ordered = node as? OrderedList var number = Int(ordered?.startIndex ?? 1) for child in node.children { guard let item = child as? ListItem else { continue } for block in item.children { if block is OrderedList || block is UnorderedList { visit(block, listDepth: listDepth + 1) } else { if let itemRange = range(item), let body = range(block) { var attributes: [NSAttributedString.Key: Any] = [NoteAttributes.block: ordered == nil ? "bullet" : "ordered", NoteAttributes.depth: listDepth] if ordered != nil { attributes[NoteAttributes.number] = number } if let checked = item.checkbox { attributes[NoteAttributes.block] = "task" switch checked { case .checked: attributes[NoteAttributes.checked] = true; case .unchecked: attributes[NoteAttributes.checked] = false } } mark(body, attributes, paragraph: true) if body.location > itemRange.location, block.indexInParent == 0 { let start = text.paragraphRange(for: NSRange(location: itemRange.location, length: 0)).location deletions.append(NSRange(location: start, length: body.location - start)) } } visit(block, listDepth: listDepth) } } number += 1 } return case is Paragraph: replacements.append((whole, inlineContents(node, sourceRange: whole))) return case is Document, is ListItem: break default: // Tables, HTML, remote image syntax and other unsupported constructs stay literal. return } for child in node.children { visit(child, listDepth: listDepth) } } }