109 lines
4.5 KiB
Swift
109 lines
4.5 KiB
Swift
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<UUID> = []
|
|
|
|
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)
|
|
}
|
|
}
|