import AppKit import SwiftUI @MainActor @Observable final class PostGeneratorViewModel { var draft = PostDraft() var selectedTab: PostGeneratorTab = .compose var isGenerating = false var errorMessage: String? var successMessage: String? var showImageImporter = false var showOverwriteConfirmation = false private let generationService: any PostGenerationServiceProtocol private var imageAccessURL: URL? private var isAccessingImageResource = false private let emptyDraft = PostDraft() init(generationService: (any PostGenerationServiceProtocol)? = nil) { self.generationService = generationService ?? PostGenerationServiceFactory.makeDefault() } var formattedSubreddit: String { let name = PostDraftValidator.normalizedSubreddit(draft.subreddit) guard !name.isEmpty else { return "r/subreddit" } return "r/\(name)" } var usesLiveAI: Bool { AIConfiguration.usesLiveAI } var hasDraftChanges: Bool { draft != emptyDraft } var canGenerate: Bool { PostDraftValidator.canGenerate(from: draft, isGenerating: isGenerating) } var canExport: Bool { PostDraftValidator.canExport(draft) } var canAddPollOption: Bool { draft.pollOptions.count < 6 } var canRemovePollOption: Bool { draft.pollOptions.count > 2 } var needsOverwriteConfirmation: Bool { !draft.title.trimmingCharacters(in: .whitespaces).isEmpty || !draft.body.trimmingCharacters(in: .whitespaces).isEmpty || draft.pollOptions.contains { !$0.text.trimmingCharacters(in: .whitespaces).isEmpty } } func selectPostType(_ type: RedditPostType) { draft.postType = type clearMessages() } func addPollOption() { guard canAddPollOption else { return } draft.pollOptions.append(PollOption()) clearMessages() } func removePollOption(_ option: PollOption) { guard canRemovePollOption else { return } draft.pollOptions.removeAll { $0.id == option.id } clearMessages() } func updatePollOption(id: UUID, text: String) { guard let index = draft.pollOptions.firstIndex(where: { $0.id == id }) else { return } draft.pollOptions[index].text = text clearMessages() } func setImage(from url: URL) { releaseImageAccess() guard url.startAccessingSecurityScopedResource() else { errorMessage = "Couldn't access the selected image. Try choosing the file again." successMessage = nil return } imageAccessURL = url isAccessingImageResource = true draft.imageFileURL = url clearMessages() } func handleImageImportFailure(_ error: Error) { errorMessage = "Image import failed: \(error.localizedDescription)" successMessage = nil } func removeImage() { releaseImageAccess() draft.imageFileURL = nil clearMessages() } func generatePost() async { guard canGenerate else { return } if needsOverwriteConfirmation { showOverwriteConfirmation = true return } await performGenerate(replaceExisting: true) } func confirmOverwriteAndGenerate() async { showOverwriteConfirmation = false await performGenerate(replaceExisting: true) } func cancelOverwriteConfirmation() { showOverwriteConfirmation = false } func copyToClipboard() { do { try PostDraftValidator.validateForExport(draft) let content = exportText() NSPasteboard.general.clearContents() NSPasteboard.general.setString(content, forType: .string) successMessage = "Copied to clipboard." errorMessage = nil } catch { errorMessage = error.localizedDescription successMessage = nil } } func openInReddit(openInApp: (URL) -> Void) { do { let url = try RedditSubmitURLBuilder.submitURL(for: draft) openInApp(url) successMessage = "Opened Reddit submit page in the app." errorMessage = nil } catch { errorMessage = error.localizedDescription successMessage = nil } } func resetDraft() { releaseImageAccess() draft = PostDraft() selectedTab = .compose clearMessages() } func clearMessages() { errorMessage = nil successMessage = nil } func notifyDraftEdited() { clearMessages() } func exportText() -> String { var lines: [String] = [] lines.append("Subreddit: \(formattedSubreddit)") lines.append("Type: \(draft.postType.title)") if !draft.title.isEmpty { lines.append("Title: \(draft.title)") } switch draft.postType { case .text: if !draft.body.isEmpty { lines.append("\n\(draft.body)") } case .image: if let url = draft.imageFileURL { lines.append("Image: \(url.lastPathComponent)") } if !draft.body.isEmpty { lines.append("Caption: \(draft.body)") } case .link: if !draft.linkURL.isEmpty { lines.append("URL: \(draft.linkURL)") } if !draft.body.isEmpty { lines.append("\n\(draft.body)") } case .video: if !draft.videoURL.isEmpty { lines.append("Video URL: \(draft.videoURL)") } if !draft.body.isEmpty { lines.append("\n\(draft.body)") } case .poll: lines.append("Duration: \(draft.pollDuration.label)") for (index, option) in draft.pollOptions.enumerated() where !option.text.isEmpty { lines.append("Option \(index + 1): \(option.text)") } if !draft.body.isEmpty { lines.append("\n\(draft.body)") } } var tags: [String] = [] if draft.isNSFW { tags.append("NSFW") } if draft.isSpoiler { tags.append("Spoiler") } if draft.isOC { tags.append("OC") } if !tags.isEmpty { lines.append("Tags: \(tags.joined(separator: ", "))") } if !draft.flair.isEmpty { lines.append("Flair: \(draft.flair)") } return lines.joined(separator: "\n") } private func performGenerate(replaceExisting: Bool) async { isGenerating = true errorMessage = nil successMessage = nil do { let result = try await generationService.generatePost(from: draft) applyGeneratedPost(result, replaceExisting: replaceExisting) selectedTab = .preview let engine = usesLiveAI ? "OpenAI" : "local AI templates" successMessage = "Post generated successfully using \(engine)." } catch { errorMessage = error.localizedDescription } isGenerating = false } private func applyGeneratedPost(_ result: GeneratedPost, replaceExisting: Bool) { if replaceExisting || draft.title.trimmingCharacters(in: .whitespaces).isEmpty { draft.title = String(result.title.prefix(PostDraftValidator.maxTitleLength)) } if replaceExisting || draft.body.trimmingCharacters(in: .whitespaces).isEmpty { draft.body = String(result.body.prefix(PostDraftValidator.maxBodyLength)) } if let flair = result.suggestedFlair, draft.flair.trimmingCharacters(in: .whitespaces).isEmpty { draft.flair = flair } if draft.postType == .poll, let options = result.pollOptions, !options.isEmpty { if replaceExisting || draft.pollOptions.allSatisfy({ $0.text.trimmingCharacters(in: .whitespaces).isEmpty }) { draft.pollOptions = options.map { PollOption(text: $0) } } } } private func releaseImageAccess() { if isAccessingImageResource, let imageAccessURL { imageAccessURL.stopAccessingSecurityScopedResource() } imageAccessURL = nil isAccessingImageResource = false } }