PostGeneratorViewModel.swift 8.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278
  1. import AppKit
  2. import SwiftUI
  3. @MainActor
  4. @Observable
  5. final class PostGeneratorViewModel {
  6. var draft = PostDraft()
  7. var selectedTab: PostGeneratorTab = .compose
  8. var isGenerating = false
  9. var errorMessage: String?
  10. var successMessage: String?
  11. var showImageImporter = false
  12. var showOverwriteConfirmation = false
  13. private var hasPremiumAccess = false
  14. private var generationService: any PostGenerationServiceProtocol
  15. private let injectedGenerationService: (any PostGenerationServiceProtocol)?
  16. private let historyManager: AIHistoryManager
  17. private var imageAccessURL: URL?
  18. private var isAccessingImageResource = false
  19. private let emptyDraft = PostDraft()
  20. init(
  21. hasPremiumAccess: Bool = false,
  22. generationService: (any PostGenerationServiceProtocol)? = nil,
  23. historyManager: AIHistoryManager = .shared
  24. ) {
  25. self.hasPremiumAccess = hasPremiumAccess
  26. self.injectedGenerationService = generationService
  27. self.historyManager = historyManager
  28. self.generationService = generationService
  29. ?? PostGenerationServiceFactory.make(hasPremiumAccess: hasPremiumAccess)
  30. }
  31. func setPremiumAccess(_ hasPremiumAccess: Bool) {
  32. self.hasPremiumAccess = hasPremiumAccess
  33. if injectedGenerationService == nil {
  34. generationService = PostGenerationServiceFactory.make(hasPremiumAccess: hasPremiumAccess)
  35. }
  36. }
  37. var formattedSubreddit: String {
  38. let name = PostDraftValidator.normalizedSubreddit(draft.subreddit)
  39. guard !name.isEmpty else { return "r/subreddit" }
  40. return "r/\(name)"
  41. }
  42. var usesLiveAI: Bool {
  43. AIConfiguration.usesLiveAI(hasPremiumAccess: hasPremiumAccess)
  44. }
  45. var hasDraftChanges: Bool {
  46. draft != emptyDraft
  47. }
  48. var canGenerate: Bool {
  49. PostDraftValidator.canGenerate(from: draft, isGenerating: isGenerating)
  50. }
  51. var canExport: Bool {
  52. PostDraftValidator.canExport(draft)
  53. }
  54. var canAddPollOption: Bool {
  55. draft.pollOptions.count < 6
  56. }
  57. var canRemovePollOption: Bool {
  58. draft.pollOptions.count > 2
  59. }
  60. var needsOverwriteConfirmation: Bool {
  61. !draft.title.trimmingCharacters(in: .whitespaces).isEmpty
  62. || !draft.body.trimmingCharacters(in: .whitespaces).isEmpty
  63. || draft.pollOptions.contains { !$0.text.trimmingCharacters(in: .whitespaces).isEmpty }
  64. }
  65. func selectPostType(_ type: RedditPostType) {
  66. draft.postType = type
  67. clearMessages()
  68. }
  69. func addPollOption() {
  70. guard canAddPollOption else { return }
  71. draft.pollOptions.append(PollOption())
  72. clearMessages()
  73. }
  74. func removePollOption(_ option: PollOption) {
  75. guard canRemovePollOption else { return }
  76. draft.pollOptions.removeAll { $0.id == option.id }
  77. clearMessages()
  78. }
  79. func updatePollOption(id: UUID, text: String) {
  80. guard let index = draft.pollOptions.firstIndex(where: { $0.id == id }) else { return }
  81. draft.pollOptions[index].text = text
  82. clearMessages()
  83. }
  84. func setImage(from url: URL) {
  85. releaseImageAccess()
  86. guard url.startAccessingSecurityScopedResource() else {
  87. errorMessage = "Couldn't access the selected image. Try choosing the file again."
  88. successMessage = nil
  89. return
  90. }
  91. imageAccessURL = url
  92. isAccessingImageResource = true
  93. draft.imageFileURL = url
  94. clearMessages()
  95. }
  96. func handleImageImportFailure(_ error: Error) {
  97. errorMessage = UserFacingError.message(for: error)
  98. successMessage = nil
  99. }
  100. func removeImage() {
  101. releaseImageAccess()
  102. draft.imageFileURL = nil
  103. clearMessages()
  104. }
  105. func generatePost() async {
  106. guard canGenerate else { return }
  107. if needsOverwriteConfirmation {
  108. showOverwriteConfirmation = true
  109. return
  110. }
  111. await performGenerate(replaceExisting: true)
  112. }
  113. func confirmOverwriteAndGenerate() async {
  114. showOverwriteConfirmation = false
  115. await performGenerate(replaceExisting: true)
  116. }
  117. func cancelOverwriteConfirmation() {
  118. showOverwriteConfirmation = false
  119. }
  120. func copyToClipboard() {
  121. do {
  122. try PostDraftValidator.validateForExport(draft)
  123. let content = exportText()
  124. NSPasteboard.general.clearContents()
  125. NSPasteboard.general.setString(content, forType: .string)
  126. successMessage = "Copied to clipboard."
  127. errorMessage = nil
  128. } catch {
  129. errorMessage = UserFacingError.message(for: error)
  130. successMessage = nil
  131. }
  132. }
  133. func resetDraft() {
  134. releaseImageAccess()
  135. draft = PostDraft()
  136. selectedTab = .compose
  137. clearMessages()
  138. }
  139. func restore(from entry: AIHistoryEntry) {
  140. guard case .postGenerator(let storedDraft) = entry.payload else { return }
  141. releaseImageAccess()
  142. draft = storedDraft.postDraft
  143. selectedTab = .preview
  144. clearMessages()
  145. successMessage = "Restored from history."
  146. }
  147. func clearMessages() {
  148. errorMessage = nil
  149. successMessage = nil
  150. }
  151. func notifyDraftEdited() {
  152. clearMessages()
  153. }
  154. func exportText() -> String {
  155. var lines: [String] = []
  156. lines.append("Subreddit: \(formattedSubreddit)")
  157. lines.append("Type: \(draft.postType.title)")
  158. if !draft.title.isEmpty {
  159. lines.append("Title: \(draft.title)")
  160. }
  161. switch draft.postType {
  162. case .text:
  163. if !draft.body.isEmpty { lines.append("\n\(draft.body)") }
  164. case .image:
  165. if let url = draft.imageFileURL {
  166. lines.append("Image: \(url.lastPathComponent)")
  167. }
  168. if !draft.body.isEmpty { lines.append("Caption: \(draft.body)") }
  169. case .link:
  170. if !draft.linkURL.isEmpty { lines.append("URL: \(draft.linkURL)") }
  171. if !draft.body.isEmpty { lines.append("\n\(draft.body)") }
  172. case .video:
  173. if !draft.videoURL.isEmpty { lines.append("Video URL: \(draft.videoURL)") }
  174. if !draft.body.isEmpty { lines.append("\n\(draft.body)") }
  175. case .poll:
  176. lines.append("Duration: \(draft.pollDuration.label)")
  177. for (index, option) in draft.pollOptions.enumerated() where !option.text.isEmpty {
  178. lines.append("Option \(index + 1): \(option.text)")
  179. }
  180. if !draft.body.isEmpty { lines.append("\n\(draft.body)") }
  181. }
  182. var tags: [String] = []
  183. if draft.isNSFW { tags.append("NSFW") }
  184. if draft.isSpoiler { tags.append("Spoiler") }
  185. if draft.isOC { tags.append("OC") }
  186. if !tags.isEmpty { lines.append("Tags: \(tags.joined(separator: ", "))") }
  187. if !draft.flair.isEmpty { lines.append("Flair: \(draft.flair)") }
  188. return lines.joined(separator: "\n")
  189. }
  190. private func performGenerate(replaceExisting: Bool) async {
  191. isGenerating = true
  192. errorMessage = nil
  193. successMessage = nil
  194. await Task.yield()
  195. do {
  196. let result = try await generationService.generatePost(from: draft)
  197. applyGeneratedPost(result, replaceExisting: replaceExisting)
  198. selectedTab = .preview
  199. let engine = usesLiveAI ? "AI" : "local AI templates"
  200. successMessage = "Post generated successfully using \(engine)."
  201. let entry = AIHistoryEntry.fromPostGenerator(draft: draft)
  202. Task { @MainActor in
  203. historyManager.save(entry)
  204. }
  205. } catch {
  206. errorMessage = UserFacingError.message(for: error)
  207. }
  208. isGenerating = false
  209. }
  210. private func applyGeneratedPost(_ result: GeneratedPost, replaceExisting: Bool) {
  211. if replaceExisting || draft.title.trimmingCharacters(in: .whitespaces).isEmpty {
  212. draft.title = String(result.title.prefix(PostDraftValidator.maxTitleLength))
  213. }
  214. if replaceExisting || draft.body.trimmingCharacters(in: .whitespaces).isEmpty {
  215. draft.body = String(result.body.prefix(PostDraftValidator.maxBodyLength))
  216. }
  217. if let flair = result.suggestedFlair, draft.flair.trimmingCharacters(in: .whitespaces).isEmpty {
  218. draft.flair = flair
  219. }
  220. if draft.postType == .poll, let options = result.pollOptions, !options.isEmpty {
  221. if replaceExisting || draft.pollOptions.allSatisfy({ $0.text.trimmingCharacters(in: .whitespaces).isEmpty }) {
  222. draft.pollOptions = options.map { PollOption(text: $0) }
  223. }
  224. }
  225. }
  226. private func releaseImageAccess() {
  227. if isAccessingImageResource, let imageAccessURL {
  228. imageAccessURL.stopAccessingSecurityScopedResource()
  229. }
  230. imageAccessURL = nil
  231. isAccessingImageResource = false
  232. }
  233. }