PostGeneratorViewModel.swift 8.1 KB

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