import AppKit public struct Note: Identifiable { public let id: UUID public let contents: NSAttributedString public let title: String init(id: UUID, contents: NSAttributedString) { self.id = id self.contents = NSAttributedString(attributedString: contents) var title = "Untitled Note" contents.string.enumerateLines { line, stop in let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) if !trimmed.isEmpty { title = String(trimmed.prefix(60)) stop = true } } self.title = title } } @MainActor public final class NotesStore { public private(set) var notes: [Note] = [] private let directory: URL private var legacyIDs: Set = [] public init(directory: URL = FileManager.default.urls( for: .applicationSupportDirectory, in: .userDomainMask )[0].appendingPathComponent("Conjure/Notes", isDirectory: true)) throws { self.directory = directory try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) let files = try FileManager.default.contentsOfDirectory( at: directory, includingPropertiesForKeys: nil ).filter { $0.pathExtension.lowercased() == "rtfd" } .sorted { $0.lastPathComponent < $1.lastPathComponent } var loaded: [Note] = [] for url in files { guard let id = UUID(uuidString: url.deletingPathExtension().lastPathComponent) else { throw CocoaError(.fileReadCorruptFile) } let wrapper = try FileWrapper(url: url, options: .immediate) guard wrapper.isDirectory else { throw CocoaError(.fileReadCorruptFile) } let contents: NSAttributedString if wrapper.fileWrappers?[NoteDocument.metadataFilename] != nil { contents = try NoteDocument.contents(from: wrapper) } else { guard let legacy = NSAttributedString(rtfdFileWrapper: wrapper, documentAttributes: nil) else { throw CocoaError(.fileReadCorruptFile) } contents = NoteDocument.importMarkdown(legacy) legacyIDs.insert(id) } loaded.append(Note(id: id, contents: contents)) } notes = loaded } public func create() throws -> UUID { let note = Note(id: UUID(), contents: NSAttributedString(string: "")) try save(note) notes.append(note) notes.sort { $0.id.uuidString < $1.id.uuidString } return note.id } public func update(id: UUID, contents: NSAttributedString) throws { guard let index = notes.firstIndex(where: { $0.id == id }) else { throw CocoaError(.fileNoSuchFile) } let note = Note(id: id, contents: contents) try save(note) notes[index] = note } public func delete(id: UUID) throws { guard let index = notes.firstIndex(where: { $0.id == id }) else { throw CocoaError(.fileNoSuchFile) } try FileManager.default.removeItem(at: directory.appendingPathComponent(id.uuidString + ".rtfd")) notes.remove(at: index) legacyIDs.remove(id) } private func save(_ note: Note) throws { let wrapper = try NoteDocument.fileWrapper(note.contents) let url = directory.appendingPathComponent(note.id.uuidString + ".rtfd", isDirectory: true) if legacyIDs.contains(note.id) { let backups = directory.appendingPathComponent("LegacyBackups", isDirectory: true) try FileManager.default.createDirectory(at: backups, withIntermediateDirectories: true) let backup = backups.appendingPathComponent(note.id.uuidString + ".rtfd", isDirectory: true) if !FileManager.default.fileExists(atPath: backup.path) { // copyItem leaves the live legacy package untouched if backup creation fails. try FileManager.default.copyItem(at: url, to: backup) } else { // A previous failed save may already have preserved the original package. let original = try FileWrapper(url: backup, options: .immediate) guard original.isDirectory, NSAttributedString(rtfdFileWrapper: original, documentAttributes: nil) != nil else { throw CocoaError(.fileWriteFileExists) } } } try wrapper.write(to: url, options: .atomic, originalContentsURL: url) legacyIDs.remove(note.id) } }