import Foundation struct WordPressClient: PostingService { let account: Account private let token: String private let rawBaseURL: String init(account: Account, token: String) { self.account = account self.token = token // format: "username:application_password" self.rawBaseURL = account.instanceURL } private var authHeader: String { "Basic \(Data(token.utf8).base64EncodedString())" } func uploadMedia(imageData: Data, filename: String, altText: String?) async throws -> String { let url = try URLNormalizer.endpointURL( baseURL: rawBaseURL, path: "/wp-json/wp/v2/media", fieldName: "WordPress site URL" ) var request = URLRequest(url: url) request.httpMethod = "POST" request.timeoutInterval = 30 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 NetworkSupport.data( for: request, context: "WordPress media upload" ) let statusCode = response.statusCode guard (200...201).contains(statusCode) else { let body = NetworkSupport.responseBody(data) 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 = try URLNormalizer.endpointURL( baseURL: rawBaseURL, path: "/wp-json/wp/v2/media/\(mediaID)", fieldName: "WordPress site URL" ) var request = URLRequest(url: url) request.httpMethod = "POST" request.timeoutInterval = 30 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 NetworkSupport.data( for: request, context: "WordPress media alt-text update" ) let statusCode = response.statusCode 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 = try URLNormalizer.endpointURL( baseURL: rawBaseURL, path: "/wp-json/wp/v2/posts", fieldName: "WordPress site URL" ) var request = URLRequest(url: url) request.httpMethod = "POST" request.timeoutInterval = 30 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 NetworkSupport.data( for: request, context: "WordPress post" ) let statusCode = response.statusCode guard (200...201).contains(statusCode) else { let errorBody = NetworkSupport.responseBody(data) 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 url = try URLNormalizer.endpointURL( baseURL: siteURL, path: "/wp-json/", fieldName: "WordPress site URL" ) var request = URLRequest(url: url) request.httpMethod = "GET" request.timeoutInterval = 30 let (data, response) = try await NetworkSupport.data( for: request, context: "WordPress API discovery" ) let statusCode = response.statusCode 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 { guard var components = URLComponents(string: authEndpoint) else { return URL(fileURLWithPath: "/") } 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 ?? URL(fileURLWithPath: "/") } static func verifyCredentials(siteURL: String, username: String, password: String) async throws -> Bool { let url = try URLNormalizer.endpointURL( baseURL: siteURL, path: "/wp-json/wp/v2/users/me", fieldName: "WordPress site URL" ) var request = URLRequest(url: url) request.timeoutInterval = 30 let creds = "\(username):\(password)" request.setValue("Basic \(Data(creds.utf8).base64EncodedString())", forHTTPHeaderField: "Authorization") let (data, response) = try await NetworkSupport.data( for: request, context: "WordPress credential verification" ) let statusCode = response.statusCode 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 = NetworkSupport.responseBody(data) 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" } } }