Pārlūkot izejas kodu

Fix history restore, dictionary races, and history persistence reliability.

Restore history entries when reopening the same tool, prevent stale dictionary lookups from overwriting newer results, capture translator/email metadata at request start, and serialize history storage with proper error logging.

Co-authored-by: Cursor <cursoragent@cursor.com>
AhtashamShahzad1 1 mēnesi atpakaļ
vecāks
revīzija
69b1dbf869

+ 44 - 20
gramora/Services/HistoryStorageService.swift

@@ -1,4 +1,5 @@
 import Foundation
+import os
 
 extension Notification.Name {
     static let historyDidChange = Notification.Name("historyDidChange")
@@ -17,43 +18,66 @@ final class HistoryStorageService: HistoryStoring {
     private let defaults: UserDefaults
     private let storageKey = "gramora.history.entries"
     private let maxEntries = 100
+    private let queue = DispatchQueue(label: "com.gramora.history.storage")
+    private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "gramora", category: "HistoryStorage")
 
     init(defaults: UserDefaults = .standard) {
         self.defaults = defaults
     }
 
     func loadEntries() -> [HistoryEntry] {
-        guard
-            let data = defaults.data(forKey: storageKey),
-            let entries = try? JSONDecoder().decode([HistoryEntry].self, from: data)
-        else {
-            return []
+        queue.sync {
+            loadEntriesUnlocked()
         }
-
-        return entries.sorted { $0.createdAt > $1.createdAt }
     }
 
     func save(_ entry: HistoryEntry) {
-        var entries = loadEntries()
-        entries.removeAll { $0.id == entry.id }
-        entries.insert(entry, at: 0)
-        entries = Array(entries.prefix(maxEntries))
-        persist(entries)
+        queue.sync {
+            var entries = loadEntriesUnlocked()
+            entries.removeAll { $0.id == entry.id }
+            entries.insert(entry, at: 0)
+            entries = Array(entries.prefix(maxEntries))
+            persistUnlocked(entries)
+        }
     }
 
     func delete(id: UUID) {
-        var entries = loadEntries()
-        entries.removeAll { $0.id == id }
-        persist(entries)
+        queue.sync {
+            var entries = loadEntriesUnlocked()
+            entries.removeAll { $0.id == id }
+            persistUnlocked(entries)
+        }
     }
 
     func clearAll() {
-        persist([])
+        queue.sync {
+            persistUnlocked([])
+        }
     }
 
-    private func persist(_ entries: [HistoryEntry]) {
-        guard let data = try? JSONEncoder().encode(entries) else { return }
-        defaults.set(data, forKey: storageKey)
-        NotificationCenter.default.post(name: .historyDidChange, object: nil)
+    private func loadEntriesUnlocked() -> [HistoryEntry] {
+        guard let data = defaults.data(forKey: storageKey) else {
+            return []
+        }
+
+        do {
+            let entries = try JSONDecoder().decode([HistoryEntry].self, from: data)
+            return entries.sorted { $0.createdAt > $1.createdAt }
+        } catch {
+            logger.error("Failed to decode history entries: \(error.localizedDescription, privacy: .public)")
+            return []
+        }
+    }
+
+    private func persistUnlocked(_ entries: [HistoryEntry]) {
+        do {
+            let data = try JSONEncoder().encode(entries)
+            defaults.set(data, forKey: storageKey)
+            DispatchQueue.main.async {
+                NotificationCenter.default.post(name: .historyDidChange, object: nil)
+            }
+        } catch {
+            logger.error("Failed to persist history entries: \(error.localizedDescription, privacy: .public)")
+        }
     }
 }

+ 18 - 7
gramora/ViewModels/DictionaryViewModel.swift

@@ -18,6 +18,8 @@ final class DictionaryViewModel: ObservableObject {
     private let service: DictionaryServicing
     private var speechSynthesizer = AVSpeechSynthesizer()
     private var audioPlayer: AVAudioPlayer?
+    private var lookupTask: Task<Void, Never>?
+    private var activeLookupID = UUID()
 
     init(service: DictionaryServicing = DictionaryService()) {
         self.service = service
@@ -38,16 +40,12 @@ final class DictionaryViewModel: ObservableObject {
             return
         }
 
-        Task {
-            await performLookUp(for: trimmed)
-        }
+        startLookUp(for: trimmed)
     }
 
     func lookUpRecent(_ word: String) {
         query = word
-        Task {
-            await performLookUp(for: word)
-        }
+        startLookUp(for: word)
     }
 
     func pasteWord() {
@@ -84,20 +82,33 @@ final class DictionaryViewModel: ObservableObject {
         speechSynthesizer.speak(utterance)
     }
 
-    private func performLookUp(for word: String) async {
+    private func startLookUp(for word: String) {
+        lookupTask?.cancel()
+        let lookupID = UUID()
+        activeLookupID = lookupID
+
+        lookupTask = Task {
+            await performLookUp(for: word, lookupID: lookupID)
+        }
+    }
+
+    private func performLookUp(for word: String, lookupID: UUID) async {
         isLoading = true
         errorMessage = nil
         entry = nil
 
         do {
             let result = try await service.lookUp(word: word)
+            guard !Task.isCancelled, activeLookupID == lookupID else { return }
             entry = result
             query = result.word
             addRecentSearch(result.word)
         } catch {
+            guard !Task.isCancelled, activeLookupID == lookupID else { return }
             errorMessage = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription
         }
 
+        guard activeLookupID == lookupID else { return }
         isLoading = false
     }
 

+ 14 - 4
gramora/ViewModels/EmailWriterViewModel.swift

@@ -82,20 +82,24 @@ final class EmailWriterViewModel: ObservableObject {
         errorMessage = nil
 
         let subjectLine = subject.trimmingCharacters(in: .whitespacesAndNewlines)
+        let requestEmailType = emailType
+        let requestTone = tone
 
         Task {
             do {
                 let email = try await emailWriterService.generateEmail(
                     prompt: trimmedPrompt,
                     subject: subjectLine,
-                    emailType: emailType,
-                    tone: tone
+                    emailType: requestEmailType,
+                    tone: requestTone
                 )
                 generatedEmail = email
                 saveToHistory(
                     subject: subjectLine,
                     prompt: trimmedPrompt,
-                    generatedEmail: email
+                    generatedEmail: email,
+                    emailType: requestEmailType,
+                    tone: requestTone
                 )
             } catch {
                 errorMessage = error.localizedDescription
@@ -116,7 +120,13 @@ final class EmailWriterViewModel: ObservableObject {
         errorMessage = nil
     }
 
-    private func saveToHistory(subject: String, prompt: String, generatedEmail: String) {
+    private func saveToHistory(
+        subject: String,
+        prompt: String,
+        generatedEmail: String,
+        emailType: EmailType,
+        tone: EmailTone
+    ) {
         let payload = HistoryPayload.emailWriter(
             EmailWriterHistoryData(
                 subject: subject,

+ 17 - 4
gramora/ViewModels/LanguageTranslatorViewModel.swift

@@ -102,14 +102,22 @@ final class LanguageTranslatorViewModel: ObservableObject {
         translatedText = ""
         errorMessage = nil
 
+        let requestSourceLanguage = sourceLanguage
+        let requestTargetLanguage = targetLanguage
+
         Task {
             do {
                 translatedText = try await translationService.translate(
                     trimmed,
-                    from: sourceLanguage,
-                    to: targetLanguage
+                    from: requestSourceLanguage,
+                    to: requestTargetLanguage
+                )
+                saveToHistory(
+                    sourceText: trimmed,
+                    translatedText: translatedText,
+                    sourceLanguage: requestSourceLanguage,
+                    targetLanguage: requestTargetLanguage
                 )
-                saveToHistory(sourceText: trimmed, translatedText: translatedText)
             } catch {
                 errorMessage = error.localizedDescription
             }
@@ -128,7 +136,12 @@ final class LanguageTranslatorViewModel: ObservableObject {
         errorMessage = nil
     }
 
-    private func saveToHistory(sourceText: String, translatedText: String) {
+    private func saveToHistory(
+        sourceText: String,
+        translatedText: String,
+        sourceLanguage: TranslationLanguage,
+        targetLanguage: TranslationLanguage
+    ) {
         let payload = HistoryPayload.languageTranslator(
             TranslationHistoryData(
                 sourceText: sourceText,

+ 3 - 0
gramora/Views/EmailWriterView.swift

@@ -47,6 +47,9 @@ struct EmailWriterView: View {
             viewModel.handlePromptChange()
         }
         .onAppear(perform: restoreIfNeeded)
+        .onChange(of: restoreEntry?.id) { _ in
+            restoreIfNeeded()
+        }
     }
 
     private func restoreIfNeeded() {

+ 3 - 0
gramora/Views/GrammarCheckerView.swift

@@ -71,6 +71,9 @@ struct GrammarCheckerView: View {
             viewModel.handleTextChange()
         }
         .onAppear(perform: restoreIfNeeded)
+        .onChange(of: restoreEntry?.id) { _ in
+            restoreIfNeeded()
+        }
     }
 
     private func loadingLayout(

+ 3 - 0
gramora/Views/JournalFinderView.swift

@@ -58,6 +58,9 @@ struct JournalFinderView: View {
             if viewModel.hasResults { viewModel.resetResults() }
         }
         .onAppear(perform: restoreIfNeeded)
+        .onChange(of: restoreEntry?.id) { _ in
+            restoreIfNeeded()
+        }
     }
 
     private func restoreIfNeeded() {

+ 3 - 0
gramora/Views/LanguageTranslatorView.swift

@@ -36,6 +36,9 @@ struct LanguageTranslatorView: View {
             viewModel.syncTargetLanguageIfNeeded()
         }
         .onAppear(perform: restoreIfNeeded)
+        .onChange(of: restoreEntry?.id) { _ in
+            restoreIfNeeded()
+        }
     }
 
     private func restoreIfNeeded() {

+ 3 - 0
gramora/Views/SpellCheckerView.swift

@@ -71,6 +71,9 @@ struct SpellCheckerView: View {
             viewModel.handleTextChange()
         }
         .onAppear(perform: restoreIfNeeded)
+        .onChange(of: restoreEntry?.id) { _ in
+            restoreIfNeeded()
+        }
     }
 
     private func restoreIfNeeded() {