PostGeneratorViewModel.swift 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258
  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 let generationService: any PostGenerationServiceProtocol
  14. private var imageAccessURL: URL?
  15. private var isAccessingImageResource = false
  16. private let emptyDraft = PostDraft()
  17. init(generationService: (any PostGenerationServiceProtocol)? = nil) {
  18. self.generationService = generationService ?? PostGenerationServiceFactory.makeDefault()
  19. }
  20. var formattedSubreddit: String {
  21. let name = PostDraftValidator.normalizedSubreddit(draft.subreddit)
  22. guard !name.isEmpty else { return "r/subreddit" }
  23. return "r/\(name)"
  24. }
  25. var usesLiveAI: Bool {
  26. AIConfiguration.usesLiveAI
  27. }
  28. var hasDraftChanges: Bool {
  29. draft != emptyDraft
  30. }
  31. var canGenerate: Bool {
  32. PostDraftValidator.canGenerate(from: draft, isGenerating: isGenerating)
  33. }
  34. var canExport: Bool {
  35. PostDraftValidator.canExport(draft)
  36. }
  37. var canAddPollOption: Bool {
  38. draft.pollOptions.count < 6
  39. }
  40. var canRemovePollOption: Bool {
  41. draft.pollOptions.count > 2
  42. }
  43. var needsOverwriteConfirmation: Bool {
  44. !draft.title.trimmingCharacters(in: .whitespaces).isEmpty
  45. || !draft.body.trimmingCharacters(in: .whitespaces).isEmpty
  46. || draft.pollOptions.contains { !$0.text.trimmingCharacters(in: .whitespaces).isEmpty }
  47. }
  48. func selectPostType(_ type: RedditPostType) {
  49. draft.postType = type
  50. clearMessages()
  51. }
  52. func addPollOption() {
  53. guard canAddPollOption else { return }
  54. draft.pollOptions.append(PollOption())
  55. clearMessages()
  56. }
  57. func removePollOption(_ option: PollOption) {
  58. guard canRemovePollOption else { return }
  59. draft.pollOptions.removeAll { $0.id == option.id }
  60. clearMessages()
  61. }
  62. func updatePollOption(id: UUID, text: String) {
  63. guard let index = draft.pollOptions.firstIndex(where: { $0.id == id }) else { return }
  64. draft.pollOptions[index].text = text
  65. clearMessages()
  66. }
  67. func setImage(from url: URL) {
  68. releaseImageAccess()
  69. guard url.startAccessingSecurityScopedResource() else {
  70. errorMessage = "Couldn't access the selected image. Try choosing the file again."
  71. successMessage = nil
  72. return
  73. }
  74. imageAccessURL = url
  75. isAccessingImageResource = true
  76. draft.imageFileURL = url
  77. clearMessages()
  78. }
  79. func handleImageImportFailure(_ error: Error) {
  80. errorMessage = "Image import failed: \(error.localizedDescription)"
  81. successMessage = nil
  82. }
  83. func removeImage() {
  84. releaseImageAccess()
  85. draft.imageFileURL = nil
  86. clearMessages()
  87. }
  88. func generatePost() async {
  89. guard canGenerate else { return }
  90. if needsOverwriteConfirmation {
  91. showOverwriteConfirmation = true
  92. return
  93. }
  94. await performGenerate(replaceExisting: true)
  95. }
  96. func confirmOverwriteAndGenerate() async {
  97. showOverwriteConfirmation = false
  98. await performGenerate(replaceExisting: true)
  99. }
  100. func cancelOverwriteConfirmation() {
  101. showOverwriteConfirmation = false
  102. }
  103. func copyToClipboard() {
  104. do {
  105. try PostDraftValidator.validateForExport(draft)
  106. let content = exportText()
  107. NSPasteboard.general.clearContents()
  108. NSPasteboard.general.setString(content, forType: .string)
  109. successMessage = "Copied to clipboard."
  110. errorMessage = nil
  111. } catch {
  112. errorMessage = error.localizedDescription
  113. successMessage = nil
  114. }
  115. }
  116. func openInReddit(openInApp: (URL) -> Void) {
  117. do {
  118. let url = try RedditSubmitURLBuilder.submitURL(for: draft)
  119. openInApp(url)
  120. successMessage = "Opened Reddit submit page in the app."
  121. errorMessage = nil
  122. } catch {
  123. errorMessage = error.localizedDescription
  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 ? "OpenAI" : "local AI templates"
  185. successMessage = "Post generated successfully using \(engine)."
  186. } catch {
  187. errorMessage = error.localizedDescription
  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. }