瀏覽代碼

Persist notes to JSON in Application Support.

Introduce NotesStore to load/save notes.json with debounced autosave, and wire the UI to use the store so notes survive app relaunch.

Made-with: Cursor
huzaifahayat12 4 月之前
父節點
當前提交
196c91fbab
共有 2 個文件被更改,包括 114 次插入31 次删除
  1. 30 31
      notes_app/NotesAppRootView.swift
  2. 84 0
      notes_app/NotesStore.swift

+ 30 - 31
notes_app/NotesAppRootView.swift

@@ -21,7 +21,7 @@ private struct NoteCollection: Identifiable, Hashable {
     let kind: Kind
 }
 
-private struct Note: Identifiable, Equatable, Hashable {
+struct Note: Identifiable, Equatable, Hashable, Codable {
     let id: UUID
     var title: String
     var body: String
@@ -45,8 +45,7 @@ struct NotesAppRootView: View {
     @State private var selectedNoteID: UUID? = nil
     @State private var editorFocus: EditorFocusField? = nil
 
-    // Keep this local for now: the goal is to design/match the UI.
-    @State private var notes: [Note] = []
+    @StateObject private var store = NotesStore()
 
     private var collections: [NoteCollection] = [
         NoteCollection(id: NotesAppRootView.defaultCollectionID, provider: .iCloud, name: "Notes", kind: .notes),
@@ -68,11 +67,11 @@ struct NotesAppRootView: View {
         } else {
             switch selectedCollection.kind {
             case .notes:
-                scoped = notes.filter { !$0.isDeleted && !$0.isArchived }
+                scoped = store.notes.filter { !$0.isDeleted && !$0.isArchived }
             case .archive:
-                scoped = notes.filter { !$0.isDeleted && $0.isArchived }
+                scoped = store.notes.filter { !$0.isDeleted && $0.isArchived }
             case .recentlyDeleted:
-                scoped = notes.filter { $0.isDeleted }
+                scoped = store.notes.filter { $0.isDeleted }
             }
         }
 
@@ -97,46 +96,46 @@ struct NotesAppRootView: View {
         )
 
         selectedCollectionID = NotesAppRootView.defaultCollectionID
-        notes.insert(newNote, at: 0)
+        store.notes.insert(newNote, at: 0)
         selectedNoteID = newNote.id
         editorFocus = .title
     }
 
     private var canDeleteSelectedNote: Bool {
         guard let selectedNoteID else { return false }
-        return notes.contains { $0.id == selectedNoteID }
+        return store.notes.contains { $0.id == selectedNoteID }
     }
 
     private var canMoveToRecentlyDeleted: Bool {
         guard let selectedCollection else { return false }
         guard let selectedNoteID else { return false }
-        guard let note = notes.first(where: { $0.id == selectedNoteID }) else { return false }
+        guard let note = store.notes.first(where: { $0.id == selectedNoteID }) else { return false }
         if note.isDeleted { return false }
         return selectedCollection.kind == .notes || selectedCollection.kind == .archive
     }
 
     private func moveSelectedNoteToRecentlyDeleted() {
         guard let selectedNoteID else { return }
-        guard let index = notes.firstIndex(where: { $0.id == selectedNoteID }) else { return }
+        guard let index = store.notes.firstIndex(where: { $0.id == selectedNoteID }) else { return }
 
-        notes[index].isDeleted = true
-        notes[index].deletedAt = Date()
+        store.notes[index].isDeleted = true
+        store.notes[index].deletedAt = Date()
 
         selectedCollectionID = NotesAppRootView.recentlyDeletedCollectionID
-        self.selectedNoteID = notes[index].id
+        self.selectedNoteID = store.notes[index].id
         editorFocus = nil
     }
 
     private var canPermanentlyDeleteSelectedNote: Bool {
         guard let selectedCollection else { return false }
         guard let selectedNoteID else { return false }
-        guard let note = notes.first(where: { $0.id == selectedNoteID }) else { return false }
+        guard let note = store.notes.first(where: { $0.id == selectedNoteID }) else { return false }
         return selectedCollection.kind == .recentlyDeleted && note.isDeleted
     }
 
     private func permanentlyDeleteSelectedNote() {
         guard let selectedNoteID else { return }
-        notes.removeAll { $0.id == selectedNoteID }
+        store.notes.removeAll { $0.id == selectedNoteID }
         self.selectedNoteID = nil
         editorFocus = nil
     }
@@ -144,29 +143,29 @@ struct NotesAppRootView: View {
     private var canRestoreSelectedNote: Bool {
         guard let selectedCollection else { return false }
         guard let selectedNoteID else { return false }
-        guard let note = notes.first(where: { $0.id == selectedNoteID }) else { return false }
+        guard let note = store.notes.first(where: { $0.id == selectedNoteID }) else { return false }
         return selectedCollection.kind == .recentlyDeleted && note.isDeleted
     }
 
     private func restoreSelectedNote() {
         guard let selectedNoteID else { return }
-        guard let index = notes.firstIndex(where: { $0.id == selectedNoteID }) else { return }
+        guard let index = store.notes.firstIndex(where: { $0.id == selectedNoteID }) else { return }
 
-        notes[index].isDeleted = false
-        notes[index].deletedAt = nil
+        store.notes[index].isDeleted = false
+        store.notes[index].deletedAt = nil
 
-        if notes[index].isArchived {
+        if store.notes[index].isArchived {
             selectedCollectionID = NotesAppRootView.archiveCollectionID
         } else {
             selectedCollectionID = NotesAppRootView.defaultCollectionID
         }
 
-        self.selectedNoteID = notes[index].id
+        self.selectedNoteID = store.notes[index].id
     }
 
     private var canArchiveSelectedNote: Bool {
         guard let selectedNoteID else { return false }
-        guard let note = notes.first(where: { $0.id == selectedNoteID }) else { return false }
+        guard let note = store.notes.first(where: { $0.id == selectedNoteID }) else { return false }
         guard let selectedCollection else { return false }
         if note.isDeleted { return false }
         return selectedCollection.kind == .notes && !note.isArchived
@@ -174,17 +173,17 @@ struct NotesAppRootView: View {
 
     private func archiveSelectedNote() {
         guard let selectedNoteID else { return }
-        guard let index = notes.firstIndex(where: { $0.id == selectedNoteID }) else { return }
+        guard let index = store.notes.firstIndex(where: { $0.id == selectedNoteID }) else { return }
 
-        notes[index].isArchived = true
+        store.notes[index].isArchived = true
         selectedCollectionID = NotesAppRootView.archiveCollectionID
-        self.selectedNoteID = notes[index].id
+        self.selectedNoteID = store.notes[index].id
         editorFocus = nil
     }
 
     private var canUnarchiveSelectedNote: Bool {
         guard let selectedNoteID else { return false }
-        guard let note = notes.first(where: { $0.id == selectedNoteID }) else { return false }
+        guard let note = store.notes.first(where: { $0.id == selectedNoteID }) else { return false }
         guard let selectedCollection else { return false }
         if note.isDeleted { return false }
         return selectedCollection.kind == .archive && note.isArchived
@@ -192,11 +191,11 @@ struct NotesAppRootView: View {
 
     private func unarchiveSelectedNote() {
         guard let selectedNoteID else { return }
-        guard let index = notes.firstIndex(where: { $0.id == selectedNoteID }) else { return }
+        guard let index = store.notes.firstIndex(where: { $0.id == selectedNoteID }) else { return }
 
-        notes[index].isArchived = false
+        store.notes[index].isArchived = false
         selectedCollectionID = NotesAppRootView.defaultCollectionID
-        self.selectedNoteID = notes[index].id
+        self.selectedNoteID = store.notes[index].id
         editorFocus = nil
     }
 
@@ -204,7 +203,7 @@ struct NotesAppRootView: View {
         NavigationSplitView {
             SidebarView(
                 collections: collections,
-                notes: notes,
+                notes: store.notes,
                 selectedCollectionID: $selectedCollectionID
             )
         } content: {
@@ -282,7 +281,7 @@ struct NotesAppRootView: View {
             .background(Color(nsColor: .textBackgroundColor))
         } detail: {
             NoteEditorView(
-                notes: $notes,
+                notes: $store.notes,
                 selectedNoteID: selectedNoteID,
                 focusField: $editorFocus
             )

+ 84 - 0
notes_app/NotesStore.swift

@@ -0,0 +1,84 @@
+import Foundation
+import Combine
+
+final class NotesStore: ObservableObject {
+    @Published var notes: [Note] = []
+
+    private let queue = DispatchQueue(label: "notes_app.NotesStore", qos: .utility)
+    private var cancellables = Set<AnyCancellable>()
+
+    init() {
+        load()
+
+        // Debounce saves so we don't write on every keystroke.
+        $notes
+            .dropFirst()
+            .debounce(for: .milliseconds(350), scheduler: DispatchQueue.main)
+            .sink { [weak self] _ in
+                self?.save()
+            }
+            .store(in: &cancellables)
+    }
+
+    private var fileURL: URL {
+        let fm = FileManager.default
+        let appSupport = fm.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
+
+        let bundleID = Bundle.main.bundleIdentifier ?? "notes_app"
+        let dir = appSupport.appendingPathComponent(bundleID, isDirectory: true)
+        return dir.appendingPathComponent("notes.json", isDirectory: false)
+    }
+
+    func load() {
+        let url = fileURL
+        queue.async { [weak self] in
+            guard let self else { return }
+
+            do {
+                let fm = FileManager.default
+                try fm.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
+
+                guard fm.fileExists(atPath: url.path) else {
+                    DispatchQueue.main.async { self.notes = [] }
+                    return
+                }
+
+                let data = try Data(contentsOf: url)
+                let decoder = JSONDecoder()
+                decoder.dateDecodingStrategy = .iso8601
+                let decoded = try decoder.decode([Note].self, from: data)
+
+                DispatchQueue.main.async {
+                    self.notes = decoded
+                }
+            } catch {
+                // If the file is corrupt/unreadable, fall back to an empty list.
+                DispatchQueue.main.async {
+                    self.notes = []
+                }
+            }
+        }
+    }
+
+    func save() {
+        let url = fileURL
+        let snapshot = notes
+
+        queue.async {
+            do {
+                let fm = FileManager.default
+                try fm.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
+
+                let encoder = JSONEncoder()
+                encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
+                encoder.dateEncodingStrategy = .iso8601
+                let data = try encoder.encode(snapshot)
+
+                try data.write(to: url, options: [.atomic])
+            } catch {
+                // Best-effort persistence; ignore write errors for now.
+            }
+        }
+    }
+}
+