import Foundation struct WordPressClient: PostingService { let account: Account private let token: String private let baseURL: String init(account: Account, token: String) { self.account = account self.token = token // format: "username:application_password" self.baseURL = account.instanceURL.trimmingCharacters(in: CharacterSet(charactersIn: "/")) } private var authHeader: String { "Basic \(Data(token.utf8).base64EncodedString())" } func uploadMedia(imageData: Data, filename: String, altText: String?) async throws -> String { let url = URL(string: "\(baseURL)/wp-json/wp/v2/media")! var request = URLRequest(url: url) request.httpMethod = "POST" request.setValue(authHeader, forHTTPHeaderField: "Authorization") request.setValue(mimeTypeFor(filename), forHTTPHeaderField: "Content-Type") request.setValue("attachment; filename=\"\(filename)\"", forHTTPHeaderField: "Content-Disposition") request.httpBody = imageData let (data, response) = try await URLSession.shared.data(for: request) let statusCode = (response as? HTTPURLResponse)?.statusCode ?? 0 guard (200...201).contains(statusCode) else { let body = String(data: data, encoding: .utf8) ?? "Unknown error" throw PostingError(message: "WordPress media upload failed (\(statusCode)): \(body)") } guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let mediaID = json["id"] as? Int else { throw PostingError(message: "Invalid response from WordPress media upload") } // Update alt text if provided if let altText, !altText.isEmpty { try await updateMediaAltText(mediaID: mediaID, altText: altText) } guard let sourceURL = json["source_url"] as? String else { throw PostingError(message: "No source_url in WordPress media response") } return sourceURL } private func updateMediaAltText(mediaID: Int, altText: String) async throws { let url = URL(string: "\(baseURL)/wp-json/wp/v2/media/\(mediaID)")! var request = URLRequest(url: url) request.httpMethod = "POST" request.setValue(authHeader, forHTTPHeaderField: "Authorization") request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.httpBody = try JSONSerialization.data(withJSONObject: ["alt_text": altText]) let (_, response) = try await URLSession.shared.data(for: request) let statusCode = (response as? HTTPURLResponse)?.statusCode ?? 0 guard statusCode == 200 else { throw PostingError(message: "Failed to update alt text (\(statusCode))") } } func createPost(text: String, mediaIDs: [String]) async throws -> URL { // mediaIDs are source_url strings for WordPress let url = URL(string: "\(baseURL)/wp-json/wp/v2/posts")! var request = URLRequest(url: url) request.httpMethod = "POST" request.setValue(authHeader, forHTTPHeaderField: "Authorization") request.setValue("application/json", forHTTPHeaderField: "Content-Type") var content = text // Append images as HTML for imageURL in mediaIDs { content += "\n" } let body: [String: Any] = [ "content": content, "status": "publish", "format": "status", ] request.httpBody = try JSONSerialization.data(withJSONObject: body) let (data, response) = try await URLSession.shared.data(for: request) let statusCode = (response as? HTTPURLResponse)?.statusCode ?? 0 guard (200...201).contains(statusCode) else { let errorBody = String(data: data, encoding: .utf8) ?? "Unknown error" throw PostingError(message: "WordPress post failed (\(statusCode)): \(errorBody)") } guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let link = json["link"] as? String, let postURL = URL(string: link) else { throw PostingError(message: "Invalid response from WordPress") } return postURL } // MARK: - Auth Discovery static func discoverAuthEndpoint(siteURL: String) async throws -> String { let base = siteURL.trimmingCharacters(in: CharacterSet(charactersIn: "/")) let url = URL(string: "\(base)/wp-json/")! var request = URLRequest(url: url) request.httpMethod = "GET" let (data, response) = try await URLSession.shared.data(for: request) let statusCode = (response as? HTTPURLResponse)?.statusCode ?? 0 guard statusCode == 200 else { throw PostingError(message: "Cannot reach WordPress API at \(siteURL)") } guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let auth = json["authentication"] as? [String: Any], let appPasswords = auth["application-passwords"] as? [String: Any], let endpoints = appPasswords["endpoints"] as? [String: Any], let authURL = endpoints["authorization"] as? String else { throw PostingError(message: "Application Passwords not available on this site. Ensure WordPress 5.6+.") } return authURL } static func buildAuthURL(authEndpoint: String) -> URL { var components = URLComponents(string: authEndpoint)! components.queryItems = [ URLQueryItem(name: "app_name", value: "qStatus"), URLQueryItem(name: "app_id", value: "a1b2c3d4-e5f6-7890-abcd-ef1234567890"), URLQueryItem(name: "success_url", value: "qstatus://wordpress-callback"), URLQueryItem(name: "reject_url", value: "qstatus://wordpress-rejected"), ] return components.url! } static func verifyCredentials(siteURL: String, username: String, password: String) async throws -> Bool { let base = siteURL.trimmingCharacters(in: CharacterSet(charactersIn: "/")) guard let url = URL(string: "\(base)/wp-json/wp/v2/users/me") else { throw PostingError(message: "Invalid site URL: \(siteURL)") } var request = URLRequest(url: url) let creds = "\(username):\(password)" request.setValue("Basic \(Data(creds.utf8).base64EncodedString())", forHTTPHeaderField: "Authorization") let (data, response): (Data, URLResponse) do { (data, response) = try await URLSession.shared.data(for: request) } catch { throw PostingError(message: "Cannot connect to \(siteURL). Check the URL and try again.") } let statusCode = (response as? HTTPURLResponse)?.statusCode ?? 0 switch statusCode { case 200: return true case 401, 403: return false case 404: throw PostingError(message: "WordPress REST API not found at \(siteURL). Check the URL.") default: let body = String(data: data, encoding: .utf8) ?? "" throw PostingError(message: "Unexpected response (\(statusCode)): \(body)") } } private func mimeTypeFor(_ filename: String) -> String { let ext = (filename as NSString).pathExtension.lowercased() switch ext { case "png": return "image/png" case "gif": return "image/gif" case "webp": return "image/webp" default: return "image/jpeg" } } }