PostGeneratorViewModel.swift 7.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246
  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 resetDraft() {
  117. releaseImageAccess()
  118. draft = PostDraft()
  119. selectedTab = .compose
  120. clearMessages()
  121. }
  122. func clearMessages() {
  123. errorMessage = nil
  124. successMessage = nil
  125. }
  126. func notifyDraftEdited() {
  127. clearMessages()
  128. }
  129. func exportText() -> String {
  130. var lines: [String] = []
  131. lines.append("Subreddit: \(formattedSubreddit)")
  132. lines.append("Type: \(draft.postType.title)")
  133. if !draft.title.isEmpty {
  134. lines.append("Title: \(draft.title)")
  135. }
  136. switch draft.postType {
  137. case .text:
  138. if !draft.body.isEmpty { lines.append("\n\(draft.body)") }
  139. case .image:
  140. if let url = draft.imageFileURL {
  141. lines.append("Image: \(url.lastPathComponent)")
  142. }
  143. if !draft.body.isEmpty { lines.append("Caption: \(draft.body)") }
  144. case .link:
  145. if !draft.linkURL.isEmpty { lines.append("URL: \(draft.linkURL)") }
  146. if !draft.body.isEmpty { lines.append("\n\(draft.body)") }
  147. case .video:
  148. if !draft.videoURL.isEmpty { lines.append("Video URL: \(draft.videoURL)") }
  149. if !draft.body.isEmpty { lines.append("\n\(draft.body)") }
  150. case .poll:
  151. lines.append("Duration: \(draft.pollDuration.label)")
  152. for (index, option) in draft.pollOptions.enumerated() where !option.text.isEmpty {
  153. lines.append("Option \(index + 1): \(option.text)")
  154. }
  155. if !draft.body.isEmpty { lines.append("\n\(draft.body)") }
  156. }
  157. var tags: [String] = []
  158. if draft.isNSFW { tags.append("NSFW") }
  159. if draft.isSpoiler { tags.append("Spoiler") }
  160. if draft.isOC { tags.append("OC") }
  161. if !tags.isEmpty { lines.append("Tags: \(tags.joined(separator: ", "))") }
  162. if !draft.flair.isEmpty { lines.append("Flair: \(draft.flair)") }
  163. return lines.joined(separator: "\n")
  164. }
  165. private func performGenerate(replaceExisting: Bool) async {
  166. isGenerating = true
  167. errorMessage = nil
  168. successMessage = nil
  169. do {
  170. let result = try await generationService.generatePost(from: draft)
  171. applyGeneratedPost(result, replaceExisting: replaceExisting)
  172. selectedTab = .preview
  173. let engine = usesLiveAI ? "OpenAI" : "local AI templates"
  174. successMessage = "Post generated successfully using \(engine)."
  175. } catch {
  176. errorMessage = error.localizedDescription
  177. }
  178. isGenerating = false
  179. }
  180. private func applyGeneratedPost(_ result: GeneratedPost, replaceExisting: Bool) {
  181. if replaceExisting || draft.title.trimmingCharacters(in: .whitespaces).isEmpty {
  182. draft.title = String(result.title.prefix(PostDraftValidator.maxTitleLength))
  183. }
  184. if replaceExisting || draft.body.trimmingCharacters(in: .whitespaces).isEmpty {
  185. draft.body = String(result.body.prefix(PostDraftValidator.maxBodyLength))
  186. }
  187. if let flair = result.suggestedFlair, draft.flair.trimmingCharacters(in: .whitespaces).isEmpty {
  188. draft.flair = flair
  189. }
  190. if draft.postType == .poll, let options = result.pollOptions, !options.isEmpty {
  191. if replaceExisting || draft.pollOptions.allSatisfy({ $0.text.trimmingCharacters(in: .whitespaces).isEmpty }) {
  192. draft.pollOptions = options.map { PollOption(text: $0) }
  193. }
  194. }
  195. }
  196. private func releaseImageAccess() {
  197. if isAccessingImageResource, let imageAccessURL {
  198. imageAccessURL.stopAccessingSecurityScopedResource()
  199. }
  200. imageAccessURL = nil
  201. isAccessingImageResource = false
  202. }
  203. }