TitleOptimizerViewModel.swift 5.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182
  1. import AppKit
  2. import SwiftUI
  3. @MainActor
  4. @Observable
  5. final class TitleOptimizerViewModel {
  6. var draft = TitleDraft()
  7. var selectedTab: TitleOptimizerTab = .optimize
  8. var isOptimizing = false
  9. var errorMessage: String?
  10. var successMessage: String?
  11. var analysis: TitleAnalysis?
  12. var variants: [TitleVariant] = []
  13. var selectedVariantID: UUID?
  14. private var hasPremiumAccess = false
  15. private let injectedOptimizationService: (any TitleOptimizationServiceProtocol)?
  16. init(
  17. hasPremiumAccess: Bool = false,
  18. optimizationService: (any TitleOptimizationServiceProtocol)? = nil
  19. ) {
  20. self.hasPremiumAccess = hasPremiumAccess
  21. self.injectedOptimizationService = optimizationService
  22. }
  23. func setPremiumAccess(_ hasPremiumAccess: Bool) {
  24. self.hasPremiumAccess = hasPremiumAccess
  25. }
  26. private var optimizationService: any TitleOptimizationServiceProtocol {
  27. injectedOptimizationService ?? TitleOptimizationServiceFactory.make(hasPremiumAccess: hasPremiumAccess)
  28. }
  29. var usesLiveAI: Bool {
  30. AIConfiguration.usesLiveAI(hasPremiumAccess: hasPremiumAccess)
  31. }
  32. var formattedSubreddit: String {
  33. let trimmed = draft.subreddit.trimmingCharacters(in: .whitespaces)
  34. guard !trimmed.isEmpty else { return "r/subreddit" }
  35. return trimmed.hasPrefix("r/") ? trimmed : "r/\(trimmed)"
  36. }
  37. var canOptimize: Bool {
  38. !isOptimizing && missingOptimizeFields.isEmpty
  39. }
  40. var missingOptimizeFields: [String] {
  41. var missing: [String] = []
  42. if draft.subreddit.trimmingCharacters(in: .whitespaces).isEmpty {
  43. missing.append("subreddit")
  44. }
  45. if draft.topic.trimmingCharacters(in: .whitespaces).isEmpty {
  46. missing.append("topic")
  47. }
  48. if draft.originalTitle.trimmingCharacters(in: .whitespaces).isEmpty {
  49. missing.append("title")
  50. }
  51. return missing
  52. }
  53. var activeTitle: String {
  54. if let selectedID = selectedVariantID,
  55. let variant = variants.first(where: { $0.id == selectedID }) {
  56. return variant.title
  57. }
  58. return draft.originalTitle
  59. }
  60. var bestVariant: TitleVariant? {
  61. variants.max(by: { $0.score < $1.score })
  62. }
  63. var previewDraft: PostDraft {
  64. PostDraft(
  65. postType: draft.postType,
  66. subreddit: draft.subreddit,
  67. topic: draft.topic,
  68. tone: draft.tone,
  69. title: activeTitle,
  70. isNSFW: draft.isNSFW,
  71. isSpoiler: draft.isSpoiler,
  72. isOC: draft.isOC,
  73. flair: draft.flair
  74. )
  75. }
  76. func selectPostType(_ type: RedditPostType) {
  77. draft.postType = type
  78. clearMessages()
  79. }
  80. func selectVariant(_ variant: TitleVariant) {
  81. selectedVariantID = variant.id
  82. clearMessages()
  83. }
  84. func applyVariant(_ variant: TitleVariant) {
  85. draft.originalTitle = variant.title
  86. selectedVariantID = variant.id
  87. successMessage = "Title applied."
  88. errorMessage = nil
  89. }
  90. func optimizeTitles() async {
  91. let missing = missingOptimizeFields
  92. guard missing.isEmpty else {
  93. errorMessage = "Fill in \(missing.joined(separator: ", ")) before optimizing."
  94. successMessage = nil
  95. return
  96. }
  97. isOptimizing = true
  98. errorMessage = nil
  99. successMessage = nil
  100. defer { isOptimizing = false }
  101. do {
  102. let result = try await optimizationService.optimizeTitles(from: draft)
  103. analysis = result.analysis
  104. variants = result.variants
  105. selectedVariantID = result.variants.first?.id
  106. let engine = usesLiveAI ? "AI" : "local AI templates"
  107. successMessage = "Generated \(result.variants.count) title suggestions using \(engine)."
  108. } catch {
  109. errorMessage = UserFacingError.message(for: error)
  110. }
  111. }
  112. func copyToClipboard() {
  113. let title = activeTitle.trimmingCharacters(in: .whitespaces)
  114. guard !title.isEmpty else { return }
  115. NSPasteboard.general.clearContents()
  116. NSPasteboard.general.setString(title, forType: .string)
  117. successMessage = "Copied to clipboard."
  118. errorMessage = nil
  119. }
  120. func resetDraft() {
  121. draft = TitleDraft()
  122. selectedTab = .optimize
  123. analysis = nil
  124. variants = []
  125. selectedVariantID = nil
  126. clearMessages()
  127. }
  128. func exportText() -> String {
  129. var lines: [String] = []
  130. lines.append("Subreddit: \(formattedSubreddit)")
  131. lines.append("Topic: \(draft.topic)")
  132. lines.append("Goal: \(draft.titleGoal.title)")
  133. lines.append("Tone: \(draft.tone.title)")
  134. lines.append("")
  135. lines.append("Original: \(draft.originalTitle)")
  136. if let analysis {
  137. lines.append("")
  138. lines.append("Analysis Score: \(analysis.overallScore)/100")
  139. lines.append(" Length: \(analysis.lengthScore)")
  140. lines.append(" Engagement: \(analysis.engagementScore)")
  141. lines.append(" Clarity: \(analysis.clarityScore)")
  142. }
  143. if !variants.isEmpty {
  144. lines.append("")
  145. lines.append("Suggestions:")
  146. for (index, variant) in variants.enumerated() {
  147. lines.append("\(index + 1). [\(variant.score)] \(variant.title)")
  148. }
  149. }
  150. return lines.joined(separator: "\n")
  151. }
  152. private func clearMessages() {
  153. errorMessage = nil
  154. successMessage = nil
  155. }
  156. }