Преглед на файлове

Fix EmailWriterViewModel Xcode scope errors by colocating dependencies.

Move shared word-limit and error helpers into Models and keep email writer types in EmailWriterModels so the indexer reliably resolves them.

Co-authored-by: Cursor <cursoragent@cursor.com>
AhtashamShahzad1 преди 1 месец
родител
ревизия
6bc16b89be

+ 102 - 0
gramora/Models/EmailWriterModels.swift

@@ -9,6 +9,108 @@ protocol EmailWriterServicing: Sendable {
     ) async throws -> String
 }
 
+struct EmailWriterService: EmailWriterServicing {
+    private let openAI: OpenAIChatServicing
+
+    init(openAI: OpenAIChatServicing = OpenAIChatService.shared) {
+        self.openAI = openAI
+    }
+
+    func generateEmail(
+        prompt: String,
+        subject: String,
+        emailType: EmailType,
+        tone: EmailTone
+    ) async throws -> String {
+        let trimmed = prompt.trimmingCharacters(in: .whitespacesAndNewlines)
+        guard !trimmed.isEmpty else {
+            throw OpenAIServiceError.apiError("Describe what the email should say.")
+        }
+
+        var userPrompt = "Email type: \(emailType.title)\nTone: \(tone.title)\n"
+        let trimmedSubject = subject.trimmingCharacters(in: .whitespacesAndNewlines)
+        if !trimmedSubject.isEmpty {
+            userPrompt += "Subject: \(trimmedSubject)\n"
+        }
+        userPrompt += "\nContent instructions:\n\(trimmed)"
+
+        return try await openAI.completeText(
+            systemPrompt: """
+            You are a professional email writer. Write a complete, ready-to-send email based on the \
+            user's instructions. Include a subject line only if one was not already provided. \
+            Return only the email text with no preamble or explanation.
+            """,
+            userPrompt: userPrompt
+        )
+    }
+}
+
+enum EmailWriterDefaults {
+    static let service: any EmailWriterServicing = EmailWriterService()
+}
+
+enum EmailWriterWordLimit {
+    static func message(maxWords: Int) -> String {
+        "Maximum \(maxWords) words allowed."
+    }
+
+    static func count(in text: String) -> Int {
+        let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
+        guard !trimmed.isEmpty else { return 0 }
+        return trimmed
+            .components(separatedBy: .whitespacesAndNewlines)
+            .filter { !$0.isEmpty }
+            .count
+    }
+
+    static func apply(_ text: String, maxWords: Int) -> (text: String, exceeded: Bool) {
+        guard count(in: text) > maxWords else { return (text, false) }
+        return (truncated(text, toMaxWords: maxWords), true)
+    }
+
+    static func applyImported(
+        _ text: String,
+        maxWords: Int,
+        setError: (String?) -> Void
+    ) -> String {
+        let (limited, exceeded) = apply(text, maxWords: maxWords)
+        setError(exceeded ? message(maxWords: maxWords) : nil)
+        return limited
+    }
+
+    private static func truncated(_ text: String, toMaxWords maxWords: Int) -> String {
+        guard maxWords > 0 else { return "" }
+        guard count(in: text) > maxWords else { return text }
+
+        var wordsSeen = 0
+        var index = text.startIndex
+
+        while index < text.endIndex {
+            while index < text.endIndex, text[index].isWhitespace {
+                index = text.index(after: index)
+            }
+            guard index < text.endIndex else { break }
+
+            wordsSeen += 1
+            while index < text.endIndex, !text[index].isWhitespace {
+                index = text.index(after: index)
+            }
+
+            if wordsSeen >= maxWords {
+                return String(text[..<index])
+            }
+        }
+
+        return text
+    }
+}
+
+enum EmailWriterErrors {
+    static func message(for error: Error) -> String {
+        UserFacingError.message(for: error)
+    }
+}
+
 enum EmailType: String, CaseIterable, Identifiable, Codable {
     case followUp
     case thankYou

+ 0 - 0
gramora/Utilities/WordLimit.swift → gramora/Models/String+WordCount.swift


+ 0 - 0
gramora/Utilities/UserFacingError.swift → gramora/Models/UserFacingError.swift


+ 0 - 37
gramora/Services/EmailWriterService.swift

@@ -1,37 +0,0 @@
-import Foundation
-
-struct EmailWriterService: EmailWriterServicing {
-    private let openAI: OpenAIChatServicing
-
-    init(openAI: OpenAIChatServicing = OpenAIChatService.shared) {
-        self.openAI = openAI
-    }
-
-    func generateEmail(
-        prompt: String,
-        subject: String,
-        emailType: EmailType,
-        tone: EmailTone
-    ) async throws -> String {
-        let trimmed = prompt.trimmingCharacters(in: .whitespacesAndNewlines)
-        guard !trimmed.isEmpty else {
-            throw OpenAIServiceError.apiError("Describe what the email should say.")
-        }
-
-        var userPrompt = "Email type: \(emailType.title)\nTone: \(tone.title)\n"
-        let trimmedSubject = subject.trimmingCharacters(in: .whitespacesAndNewlines)
-        if !trimmedSubject.isEmpty {
-            userPrompt += "Subject: \(trimmedSubject)\n"
-        }
-        userPrompt += "\nContent instructions:\n\(trimmed)"
-
-        return try await openAI.completeText(
-            systemPrompt: """
-            You are a professional email writer. Write a complete, ready-to-send email based on the \
-            user's instructions. Include a subject line only if one was not already provided. \
-            Return only the email text with no preamble or explanation.
-            """,
-            userPrompt: userPrompt
-        )
-    }
-}

+ 8 - 8
gramora/ViewModels/EmailWriterViewModel.swift

@@ -18,7 +18,7 @@ final class EmailWriterViewModel: ObservableObject {
     private let historyStorage: HistoryStoring
 
     init(
-        emailWriterService: any EmailWriterServicing = EmailWriterService(),
+        emailWriterService: any EmailWriterServicing = EmailWriterDefaults.service,
         historyStorage: HistoryStoring = HistoryStorageService.shared
     ) {
         self.emailWriterService = emailWriterService
@@ -26,7 +26,7 @@ final class EmailWriterViewModel: ObservableObject {
     }
 
     var wordCountLimitLabel: String {
-        "\(prompt.wordCount)/\(Self.maxWords)"
+        "\(EmailWriterWordLimit.count(in: prompt))/\(Self.maxWords)"
     }
 
     var canGenerate: Bool {
@@ -39,20 +39,20 @@ final class EmailWriterViewModel: ObservableObject {
 
     func pastePrompt() {
         guard let clipboard = NSPasteboard.general.string(forType: .string) else { return }
-        prompt = WordLimit.applyImported(clipboard, maxWords: Self.maxWords, setError: { errorMessage = $0 })
+        prompt = EmailWriterWordLimit.applyImported(clipboard, maxWords: Self.maxWords, setError: { errorMessage = $0 })
         generatedEmail = ""
     }
 
     func notifyWordLimitReached() {
-        errorMessage = WordLimit.message(maxWords: Self.maxWords)
+        errorMessage = EmailWriterWordLimit.message(maxWords: Self.maxWords)
     }
 
     func handlePromptChange() {
-        let (limited, exceeded) = WordLimit.apply(prompt, maxWords: Self.maxWords)
+        let (limited, exceeded) = EmailWriterWordLimit.apply(prompt, maxWords: Self.maxWords)
         if exceeded {
             prompt = limited
-            errorMessage = WordLimit.message(maxWords: Self.maxWords)
-        } else if errorMessage == WordLimit.message(maxWords: Self.maxWords) {
+            errorMessage = EmailWriterWordLimit.message(maxWords: Self.maxWords)
+        } else if errorMessage == EmailWriterWordLimit.message(maxWords: Self.maxWords) {
             errorMessage = nil
         }
         if !generatedEmail.isEmpty {
@@ -102,7 +102,7 @@ final class EmailWriterViewModel: ObservableObject {
                     tone: requestTone
                 )
             } catch {
-                errorMessage = UserFacingError.message(for: error)
+                errorMessage = EmailWriterErrors.message(for: error)
             }
 
             isGenerating = false