diff --git a/Modules/Package.resolved b/Modules/Package.resolved index 28f6408cf92c..00397dcbeea2 100644 --- a/Modules/Package.resolved +++ b/Modules/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "1779107faa0c84b72d4643d5b28ad85b6723c3b458190041157b8e8ffc6699e7", + "originHash" : "14c576a9c14b361f88a70c70a57c96d88dc6416cf1e2b0def542d10f2e4b6213", "pins" : [ { "identity" : "alamofire", @@ -376,15 +376,6 @@ "revision" : "8fa6532ea087bbc7dca60e027da0a1e82ea5dee8" } }, - { - "identity" : "wordpresskit-ios", - "kind" : "remoteSourceControl", - "location" : "https://github.com/wordpress-mobile/WordPressKit-iOS", - "state" : { - "branch" : "rework-spm", - "revision" : "6a8e615741fc86b028a85f58a9d638e57a6affcb" - } - }, { "identity" : "wpxmlrpc", "kind" : "remoteSourceControl", diff --git a/Modules/Package.swift b/Modules/Package.swift index f6f5bffa488e..1fc65ccbe277 100644 --- a/Modules/Package.swift +++ b/Modules/Package.swift @@ -49,10 +49,6 @@ let package = Package( .package(url: "https://github.com/wordpress-mobile/NSObject-SafeExpectations", from: "0.0.6"), .package(url: "https://github.com/wordpress-mobile/wpxmlrpc", from: "0.9.0"), .package(url: "https://github.com/wordpress-mobile/NSURL-IDN", revision: "b34794c9a3f32312e1593d4a3d120572afa0d010"), - .package( - url: "https://github.com/wordpress-mobile/WordPressKit-iOS", - branch: "rework-spm" - ), .package(url: "https://github.com/zendesk/support_sdk_ios", from: "8.0.3"), // We can't use wordpress-rs branches nor commits here. Only tags work. .package(url: "https://github.com/Automattic/wordpress-rs", revision: "alpha-20250901"), @@ -88,9 +84,9 @@ let package = Package( dependencies: [ "WordPressShared", "WordPressUI", - .product(name: "Gridicons", package: "Gridicons-iOS"), // TODO: Remove — It's here just for a NSMutableParagraphStyle init helper - .product(name: "WordPressKit", package: "WordPressKit-iOS"), + "WordPressKit", + .product(name: "Gridicons", package: "Gridicons-iOS"), ], // Set to v5 to avoid @Sendable warnings and errors swiftSettings: [.swiftLanguageMode(.v5)] @@ -99,7 +95,7 @@ let package = Package( name: "JetpackStats", dependencies: [ "WordPressUI", - .product(name: "WordPressKit", package: "WordPressKit-iOS"), + "WordPressKit", ], resources: [.process("Resources")] ), @@ -110,6 +106,7 @@ let package = Package( "BuildSettingsKit", "SFHFKeychainUtils", "WordPressShared", + "WordPressKit", // Even though the extension is all in Swift, we need to include the Objective-C // version of CocoaLumberjack to avoid linking issues with other dependencies that // use it. @@ -121,7 +118,6 @@ let package = Package( // in SharedCoreDataStack.o .product(name: "CocoaLumberjack", package: "CocoaLumberjack"), .product(name: "CocoaLumberjackSwift", package: "CocoaLumberjack"), - .product(name: "WordPressKit", package: "WordPressKit-iOS"), ], resources: [.process("Resources/Extensions.xcdatamodeld")] ), @@ -176,6 +172,35 @@ let package = Package( resources: [.process("Resources")], swiftSettings: [.swiftLanguageMode(.v5)] ), + .target(name: "WordPressKitObjCUtils"), + .target( + name: "WordPressKitModels", + dependencies: [ + "NSObject-SafeExpectations", + "WordPressKitObjCUtils", + ] + ), + .target( + name: "WordPressKitObjC", + dependencies: [ + "NSObject-SafeExpectations", + "wpxmlrpc", + "WordPressKitModels", + "WordPressKitObjCUtils", + ], + publicHeadersPath: "include" + ), + .target( + name: "WordPressKit", + dependencies: [ + "WordPressKitObjC", + "WordPressKitModels", + "WordPressKitObjCUtils", + "NSObject-SafeExpectations", + "wpxmlrpc", + ], + swiftSettings: [.swiftLanguageMode(.v5)] + ), .target( name: "WordPressReader", dependencies: ["AsyncImageKit", "WordPressUI", "WordPressShared"], @@ -240,10 +265,10 @@ enum XcodeSupport { "BuildSettingsKit", "WordPressShared", "WordPressUI", + "WordPressKit", .product(name: "Gridicons", package: "Gridicons-iOS"), .product(name: "NSURL-IDN", package: "NSURL-IDN"), .product(name: "SVProgressHUD", package: "SVProgressHUD"), - .product(name: "WordPressKit", package: "WordPressKit-iOS"), .product(name: "Gravatar", package: "Gravatar-SDK-iOS"), .product(name: "GravatarUI", package: "Gravatar-SDK-iOS"), ] @@ -257,6 +282,7 @@ enum XcodeSupport { "WordPressUI", "TextBundle", "TracksMini", + "WordPressKit", // Even though the extensions are all in Swift, we need to include the Objective-C // version of CocoaLumberjack to avoid linking issues with other dependencies that // use it. @@ -275,7 +301,6 @@ enum XcodeSupport { .product(name: "ZIPFoundation", package: "ZIPFoundation"), .product(name: "Aztec", package: "AztecEditor-iOS"), .product(name: "WordPressEditor", package: "AztecEditor-iOS"), - .product(name: "WordPressKit", package: "WordPressKit-iOS"), ] let testDependencies: [Target.Dependency] = [ @@ -300,6 +325,7 @@ enum XcodeSupport { "WordPressReader", "WordPressUI", "WordPressCore", + "WordPressKit", .product(name: "Alamofire", package: "Alamofire"), .product(name: "AutomatticAbout", package: "AutomatticAbout-swift"), .product(name: "AutomatticTracks", package: "Automattic-Tracks-iOS"), @@ -322,7 +348,6 @@ enum XcodeSupport { .product(name: "SVProgressHUD", package: "SVProgressHUD"), .product(name: "SwiftSoup", package: "SwiftSoup"), .product(name: "UIDeviceIdentifier", package: "UIDeviceIdentifier"), - .product(name: "WordPressKit", package: "WordPressKit-iOS"), .product(name: "ZendeskSupportSDK", package: "support_sdk_ios"), .product(name: "ZIPFoundation", package: "ZIPFoundation"), .product(name: "WordPressAPI", package: "wordpress-rs"), @@ -345,16 +370,16 @@ enum XcodeSupport { "BuildSettingsKit", "FormattableContentKit", "SFHFKeychainUtils", + "WordPressKit", .product(name: "CocoaLumberjack", package: "CocoaLumberjack"), .product(name: "CocoaLumberjackSwift", package: "CocoaLumberjack"), .product(name: "NSObject-SafeExpectations", package: "NSObject-SafeExpectations"), .product(name: "NSURL-IDN", package: "NSURL-IDN"), .product(name: "WordPressAPI", package: "wordpress-rs"), - .product(name: "WordPressKit", package: "WordPressKit-iOS"), ]), .xcodeTarget("XcodeTarget_WordPressKitTests", dependencies: testDependencies + [ "wpxmlrpc", - .product(name: "WordPressKit", package: "WordPressKit-iOS"), + "WordPressKit", ]), .xcodeTarget("XcodeTarget_WordPressAuthentificator", dependencies: wordPresAuthentificatorDependencies), .xcodeTarget("XcodeTarget_WordPressAuthentificatorTests", dependencies: wordPresAuthentificatorDependencies + testDependencies), @@ -387,6 +412,7 @@ enum XcodeSupport { "TracksMini", "WordPressShared", "WordPressUI", + "WordPressKit", // Even though the extensions are all in Swift, we need to include the Objective-C // version of CocoaLumberjack to avoid linking issues with other dependencies that // use it. @@ -399,7 +425,6 @@ enum XcodeSupport { .product(name: "CocoaLumberjack", package: "CocoaLumberjack"), .product(name: "CocoaLumberjackSwift", package: "CocoaLumberjack"), .product(name: "WordPressAPI", package: "wordpress-rs"), - .product(name: "WordPressKit", package: "WordPressKit-iOS"), ]), .xcodeTarget("XcodeTarget_Intents", dependencies: [ "BuildSettingsKit", @@ -427,13 +452,13 @@ enum XcodeSupport { "FormattableContentKit", "SFHFKeychainUtils", "WordPressShared", + "WordPressKit", .product(name: "CocoaLumberjack", package: "CocoaLumberjack"), .product(name: "CocoaLumberjackSwift", package: "CocoaLumberjack"), .product(name: "Gravatar", package: "Gravatar-SDK-iOS"), .product(name: "NSObject-SafeExpectations", package: "NSObject-SafeExpectations"), .product(name: "NSURL-IDN", package: "NSURL-IDN"), .product(name: "WordPressAPI", package: "wordpress-rs"), - .product(name: "WordPressKit", package: "WordPressKit-iOS"), ] ), ] diff --git a/Modules/Sources/WordPressKit/AccountServiceRemoteREST+SocialService.swift b/Modules/Sources/WordPressKit/AccountServiceRemoteREST+SocialService.swift new file mode 100644 index 000000000000..7500b2882d5e --- /dev/null +++ b/Modules/Sources/WordPressKit/AccountServiceRemoteREST+SocialService.swift @@ -0,0 +1,82 @@ +import Foundation + +@frozen public enum SocialServiceName: String { + case google + case apple +} + +extension AccountServiceRemoteREST { + + /// Connect to the specified social service via its OpenID Connect (JWT) token. + /// + /// - Parameters: + /// - service The name of the social service. + /// - token The OpenID Connect (JWT) ID token identifying the user on the social service. + /// - connectParameters Dictionary containing additional endpoint parameters. Currently only used for the Apple service. + /// - oAuthClientID The WPCOM REST API client ID. + /// - oAuthClientSecret The WPCOM REST API client secret. + /// - success The block that will be executed on success. + /// - failure The block that will be executed on failure. + public func connectToSocialService(_ service: SocialServiceName, + serviceIDToken token: String, + connectParameters: [String: AnyObject]? = nil, + oAuthClientID: String, + oAuthClientSecret: String, + success: @escaping (() -> Void), + failure: @escaping ((Error) -> Void)) { + let path = self.path(forEndpoint: "me/social-login/connect", withVersion: ._1_1) + + var params = [ + "client_id": oAuthClientID, + "client_secret": oAuthClientSecret, + "service": service.rawValue, + "id_token": token + ] as [String: AnyObject] + + if let connectParameters { + params.merge(connectParameters, uniquingKeysWith: { (current, _) in current }) + } + + wordPressComRESTAPI.post(path, parameters: params, success: { (_, _) in + success() + }, failure: { (error, _) in + failure(error) + }) + } + + /// Get Apple connect parameters from provided account information. + /// + /// - Parameters: + /// - email Email from Apple account. + /// - fullName User's full name from Apple account. + /// - Returns: Dictionary with endpoint parameters, to be used when connecting to social service. + static public func appleSignInParameters(email: String, fullName: String) -> [String: AnyObject] { + return [ + "user_email": email as AnyObject, + "user_name": fullName as AnyObject + ] + } + + /// Disconnect fromm the specified social service. + /// + /// - Parameters: + /// - service The name of the social service. + /// - oAuthClientID The WPCOM REST API client ID. + /// - oAuthClientSecret The WPCOM REST API client secret. + /// - success The block that will be executed on success. + /// - failure The block that will be executed on failure. + public func disconnectFromSocialService(_ service: SocialServiceName, oAuthClientID: String, oAuthClientSecret: String, success: @escaping(() -> Void), failure: @escaping((Error) -> Void)) { + let path = self.path(forEndpoint: "me/social-login/disconnect", withVersion: ._1_1) + let params = [ + "client_id": oAuthClientID, + "client_secret": oAuthClientSecret, + "service": service.rawValue + ] as [String: AnyObject] + + wordPressComRESTAPI.post(path, parameters: params, success: { (_, _) in + success() + }, failure: { (error, _) in + failure(error) + }) + } +} diff --git a/Modules/Sources/WordPressKit/AccountSettings.swift b/Modules/Sources/WordPressKit/AccountSettings.swift new file mode 100644 index 000000000000..91993fa4034c --- /dev/null +++ b/Modules/Sources/WordPressKit/AccountSettings.swift @@ -0,0 +1,95 @@ +import Foundation + +public struct AccountSettings { + // MARK: - My Profile + public let firstName: String // first_name + public let lastName: String // last_name + public let displayName: String // display_name + public let aboutMe: String // description + + // MARK: - Account Settings + public let username: String // user_login + public let usernameCanBeChanged: Bool // user_login_can_be_changed + public let email: String // user_email + public let emailPendingAddress: String? // new_user_email + public let emailPendingChange: Bool // user_email_change_pending + public let primarySiteID: Int // primary_site_ID + public let webAddress: String // user_URL + public let language: String // language + public let tracksOptOut: Bool + public let blockEmailNotifications: Bool + public let twoStepEnabled: Bool // two_step_enabled + + public init(firstName: String, + lastName: String, + displayName: String, + aboutMe: String, + username: String, + usernameCanBeChanged: Bool, + email: String, + emailPendingAddress: String?, + emailPendingChange: Bool, + primarySiteID: Int, + webAddress: String, + language: String, + tracksOptOut: Bool, + blockEmailNotifications: Bool, + twoStepEnabled: Bool) { + self.firstName = firstName + self.lastName = lastName + self.displayName = displayName + self.aboutMe = aboutMe + self.username = username + self.usernameCanBeChanged = usernameCanBeChanged + self.email = email + self.emailPendingAddress = emailPendingAddress + self.emailPendingChange = emailPendingChange + self.primarySiteID = primarySiteID + self.webAddress = webAddress + self.language = language + self.tracksOptOut = tracksOptOut + self.blockEmailNotifications = blockEmailNotifications + self.twoStepEnabled = twoStepEnabled + } +} + +@frozen public enum AccountSettingsChange { + case firstName(String) + case lastName(String) + case displayName(String) + case aboutMe(String) + case email(String) + case emailRevertPendingChange + case primarySite(Int) + case webAddress(String) + case language(String) + case tracksOptOut(Bool) + + var stringValue: String { + switch self { + case .firstName(let value): + return value + case .lastName(let value): + return value + case .displayName(let value): + return value + case .aboutMe(let value): + return value + case .email(let value): + return value + case .emailRevertPendingChange: + return String(false) + case .primarySite(let value): + return String(value) + case .webAddress(let value): + return value + case .language(let value): + return value + case .tracksOptOut(let value): + return String(value) + } + } +} + +public typealias AccountSettingsChangeWithString = (String) -> AccountSettingsChange +public typealias AccountSettingsChangeWithInt = (Int) -> AccountSettingsChange diff --git a/Modules/Sources/WordPressKit/AccountSettingsRemote.swift b/Modules/Sources/WordPressKit/AccountSettingsRemote.swift new file mode 100644 index 000000000000..37dafc1508d0 --- /dev/null +++ b/Modules/Sources/WordPressKit/AccountSettingsRemote.swift @@ -0,0 +1,228 @@ +import Foundation +import WordPressKitObjC + +public class AccountSettingsRemote: ServiceRemoteWordPressComREST { + @objc public static let remotes = NSMapTable(keyOptions: NSPointerFunctions.Options(), valueOptions: NSPointerFunctions.Options.weakMemory) + + /// Returns an AccountSettingsRemote with the given api, reusing a previous + /// remote if it exists. + @objc public static func remoteWithApi(_ api: WordPressComRestApi) -> AccountSettingsRemote { + // We're hashing on the authToken because we don't want duplicate api + // objects for the same account. + // + // In theory this would be taken care of by the fact that the api comes + // from a WPAccount, and since WPAccount is a managed object Core Data + // guarantees there's only one of it. + // + // However it might be possible that the account gets deallocated and + // when it's fetched again it would create a different api object. + // FIXME: not thread safe + // @koke 2016-01-21 + if let remote = remotes.object(forKey: api) as? AccountSettingsRemote { + return remote + } else { + let remote = AccountSettingsRemote(wordPressComRestApi: api) + remotes.setObject(remote, forKey: api) + return remote + } + } + + public func getSettings(success: @escaping (AccountSettings) -> Void, failure: @escaping (Error) -> Void) { + let endpoint = "me/settings" + let parameters = ["context": "edit"] + let path = self.path(forEndpoint: endpoint, withVersion: ._1_1) + + wordPressComRESTAPI.get(path, + parameters: parameters as [String: AnyObject]?, + success: { + responseObject, _ in + + do { + let settings = try self.settingsFromResponse(responseObject) + success(settings) + } catch { + failure(error) + } + }, + failure: { error, _ in + failure(error) + }) + } + + public func updateSetting(_ change: AccountSettingsChange, success: @escaping () -> Void, failure: @escaping (Error) -> Void) { + let endpoint = "me/settings" + let path = self.path(forEndpoint: endpoint, withVersion: ._1_1) + let parameters = [fieldNameForChange(change): change.stringValue] + + wordPressComRESTAPI.post(path, + parameters: parameters as [String: AnyObject]?, + success: { + _, _ in + + success() + }, + failure: { error, _ in + failure(error) + }) + } + + /// Change the current user's username + /// + /// - Parameters: + /// - username: the new username + /// - success: block for success + /// - failure: block for failure + public func changeUsername(to username: String, success: @escaping () -> Void, failure: @escaping () -> Void) { + let endpoint = "me/username" + let action = "none" + let parameters = ["username": username, "action": action] + + let path = self.path(forEndpoint: endpoint, withVersion: ._1_1) + + wordPressComRESTAPI.post(path, + parameters: parameters as [String: AnyObject]?, + success: { _, _ in + success() + }, + failure: { _, _ in + failure() + }) + } + + /// Validate the current user's username + /// + /// - Parameters: + /// - username: The new username + /// - success: block for success + /// - failure: block for failure + public func validateUsername(to username: String, success: @escaping () -> Void, failure: @escaping (Error) -> Void) { + let endpoint = "me/username/validate/\(username)" + let path = self.path(forEndpoint: endpoint, withVersion: ._1_1) + + wordPressComRESTAPI.get(path, + parameters: nil, + success: { _, _ in + // The success block needs to be changed if + // any allowed_actions is required + // by the changeUsername API + success() + }, + failure: { error, _ in + failure(error) + }) + } + + public func suggestUsernames(base: String, finished: @escaping ([String]) -> Void) { + let endpoint = "wpcom/v2/users/username/suggestions" + let parameters = ["name": base] + + wordPressComRESTAPI.get(endpoint, parameters: parameters as [String: AnyObject]?, success: { (responseObject, _) in + guard let response = responseObject as? [String: AnyObject], + let suggestions = response["suggestions"] as? [String] else { + finished([]) + return + } + + finished(suggestions) + }) { (_, _) in + finished([]) + } + } + + public func updatePassword(_ password: String, success: @escaping () -> Void, failure: @escaping (Error) -> Void) { + let endpoint = "me/settings" + let path = self.path(forEndpoint: endpoint, withVersion: ._1_1) + let parameters = ["password": password] + + wordPressComRESTAPI.post(path, + parameters: parameters as [String: AnyObject]?, + success: { + _, _ in + success() + }, + failure: { error, _ in + failure(error) + }) + } + + public func closeAccount(success: @escaping () -> Void, failure: @escaping (Error) -> Void) { + let endpoint = "me/account/close" + let path = path(forEndpoint: endpoint, withVersion: ._1_1) + + wordPressComRESTAPI.post(path, parameters: nil) { _, _ in + success() + } failure: { error, _ in + failure(error) + } + } + + private func settingsFromResponse(_ responseObject: Any) throws -> AccountSettings { + guard let response = responseObject as? [String: AnyObject], + let firstName = response["first_name"] as? String, + let lastName = response["last_name"] as? String, + let displayName = response["display_name"] as? String, + let aboutMe = response["description"] as? String, + let username = response["user_login"] as? String, + let usernameCanBeChanged = response["user_login_can_be_changed"] as? Bool, + let email = response["user_email"] as? String, + let emailPendingAddress = response["new_user_email"] as? String?, + let emailPendingChange = response["user_email_change_pending"] as? Bool, + let primarySiteID = response["primary_site_ID"] as? Int, + let webAddress = response["user_URL"] as? String, + let language = response["language"] as? String, + let tracksOptOut = response["tracks_opt_out"] as? Bool, + let blockEmailNotifications = response["subscription_delivery_email_blocked"] as? Bool, + let twoStepEnabled = response["two_step_enabled"] as? Bool + else { + WPKitLogError("Error decoding me/settings response: \(responseObject)") + throw ResponseError.decodingFailure + } + + let aboutMeText = aboutMe.wpkit_stringByDecodingXMLCharacters() + + return AccountSettings(firstName: firstName, + lastName: lastName, + displayName: displayName, + aboutMe: aboutMeText!, + username: username, + usernameCanBeChanged: usernameCanBeChanged, + email: email, + emailPendingAddress: emailPendingAddress, + emailPendingChange: emailPendingChange, + primarySiteID: primarySiteID, + webAddress: webAddress, + language: language, + tracksOptOut: tracksOptOut, + blockEmailNotifications: blockEmailNotifications, + twoStepEnabled: twoStepEnabled) + } + + private func fieldNameForChange(_ change: AccountSettingsChange) -> String { + switch change { + case .firstName: + return "first_name" + case .lastName: + return "last_name" + case .displayName: + return "display_name" + case .aboutMe: + return "description" + case .email: + return "user_email" + case .emailRevertPendingChange: + return "user_email_change_pending" + case .primarySite: + return "primary_site_ID" + case .webAddress: + return "user_URL" + case .language: + return "language" + case .tracksOptOut: + return "tracks_opt_out" + } + } + + enum ResponseError: Error { + case decodingFailure + } +} diff --git a/Modules/Sources/WordPressKit/Activity.swift b/Modules/Sources/WordPressKit/Activity.swift new file mode 100644 index 000000000000..55851f239a5c --- /dev/null +++ b/Modules/Sources/WordPressKit/Activity.swift @@ -0,0 +1,311 @@ +import Foundation +import WordPressKitModels + +public struct Activity: Decodable { + + private enum CodingKeys: String, CodingKey { + case activityId = "activity_id" + case summary + case content + case published + case name + case type + case gridicon + case status + case isRewindable = "is_rewindable" + case rewindId = "rewind_id" + case actor + case object + case items + } + + public let activityID: String + public let summary: String + public let text: String + public let name: String + public let type: String + public let gridicon: String + public let status: String + public let rewindID: String? + public let published: Date + public let actor: ActivityActor? + public let object: ActivityObject? + public let target: ActivityObject? + public let items: [ActivityObject]? + public let content: [String: Any]? + + private let rewindable: Bool + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + guard let id = try container.decodeIfPresent(String.self, forKey: .activityId) else { + throw Error.missingActivityId + } + guard let summaryText = try container.decodeIfPresent(String.self, forKey: .summary) else { + throw Error.missingSummary + } + guard let content = try container.decodeIfPresent([String: Any].self, forKey: .content), + let contentText = content["text"] as? String else { + throw Error.missingContentText + } + guard + let publishedString = try container.decodeIfPresent(String.self, forKey: .published), + let published = Date.dateWithISO8601WithMillisecondsString(publishedString) else { + throw Error.missingPublishedDate + } + + self.activityID = id + self.summary = summaryText + self.content = content + self.text = contentText + self.published = published + self.name = try container.decodeIfPresent(String.self, forKey: .name) ?? "" + self.type = try container.decodeIfPresent(String.self, forKey: .type) ?? "" + self.gridicon = try container.decodeIfPresent(String.self, forKey: .gridicon) ?? "" + self.status = try container.decodeIfPresent(String.self, forKey: .status) ?? "" + self.rewindable = try container.decodeIfPresent(Bool.self, forKey: .isRewindable) ?? false + self.rewindID = try container.decodeIfPresent(String.self, forKey: .rewindId) + + if let actorData = try container.decodeIfPresent([String: Any].self, forKey: .actor) { + self.actor = ActivityActor(dictionary: actorData) + } else { + self.actor = nil + } + + if let objectData = try container.decodeIfPresent([String: Any].self, forKey: .object) { + self.object = ActivityObject(dictionary: objectData) + } else { + self.object = nil + } + + if let targetData = try container.decodeIfPresent([String: Any].self, forKey: .actor) { + self.target = ActivityObject(dictionary: targetData) + } else { + self.target = nil + } + + if let orderedItems = try container.decodeIfPresent(Array.self, forKey: .items) as? [[String: Any]] { + self.items = orderedItems.map { ActivityObject(dictionary: $0) } + } else { + self.items = nil + } + } + + public var isRewindComplete: Bool { + return self.name == ActivityName.rewindComplete + } + + public var isFullBackup: Bool { + return self.name == ActivityName.fullBackup + } + + public var isRewindable: Bool { + return rewindID != nil && rewindable + } +} + +private extension Activity { + enum Error: Swift.Error { + case missingActivityId + case missingSummary + case missingContentText + case missingPublishedDate + case incorrectPusblishedDateFormat + } +} + +public struct ActivityActor { + public let displayName: String + public let type: String + public let wpcomUserID: String + public let avatarURL: String + public let role: String + + init(dictionary: [String: Any]) { + displayName = dictionary["name"] as? String ?? "" + type = dictionary["type"] as? String ?? "" + wpcomUserID = dictionary["wp_com_user_id"] as? String ?? "" + if let iconInfo = dictionary["icon"] as? [String: AnyObject] { + avatarURL = iconInfo["url"] as? String ?? "" + } else { + avatarURL = "" + } + role = dictionary["role"] as? String ?? "" + } + + public lazy var isJetpack: Bool = { + return self.type == ActivityActorType.application && + self.displayName == ActivityActorApplicationType.jetpack + }() +} + +public struct ActivityObject { + public let name: String + public let type: String + public let attributes: [String: Any] + + init(dictionary: [String: Any]) { + name = dictionary["name"] as? String ?? "" + type = dictionary["type"] as? String ?? "" + let mutableDictionary = NSMutableDictionary(dictionary: dictionary) + mutableDictionary.removeObjects(forKeys: ["name", "type"]) + if let extraAttributes = mutableDictionary as? [String: Any] { + attributes = extraAttributes + } else { + attributes = [:] + } + } +} + +public struct ActivityName { + public static let fullBackup = "rewind__backup_complete_full" + public static let rewindComplete = "rewind__complete" +} + +public struct ActivityActorType { + public static let person = "Person" + public static let application = "Application" +} + +public struct ActivityActorApplicationType { + public static let jetpack = "Jetpack" +} + +public struct ActivityStatus { + public static let error = "error" + public static let success = "success" + public static let warning = "warning" +} + +public class ActivityGroup { + public let key: String + public let name: String + public let count: Int + + public init(_ groupKey: String, dictionary: [String: AnyObject]) throws { + guard let groupName = dictionary["name"] as? String else { + throw Error.missingName + } + guard let groupCount = dictionary["count"] as? Int else { + throw Error.missingCount + } + + key = groupKey + name = groupName + count = groupCount + } +} + +private extension ActivityGroup { + enum Error: Swift.Error { + case missingName + case missingCount + } +} + +public class RewindStatus { + public let state: State + public let lastUpdated: Date + public let reason: String? + public let restore: RestoreStatus? + + internal init(state: State) { + // FIXME: A hack to support free WPCom sites and Rewind. Should be obsolote as soon as the backend + // stops returning 412's for those sites. + self.state = state + self.lastUpdated = Date() + self.reason = nil + self.restore = nil + } + + init(dictionary: [String: AnyObject]) throws { + guard let rewindState = dictionary["state"] as? String else { + throw Error.missingState + } + guard let rewindStateEnum = State(rawValue: rewindState) else { + throw Error.invalidRewindState + } + guard let lastUpdatedString = dictionary["last_updated"] as? String else { + throw Error.missingLastUpdatedDate + } + guard let lastUpdatedDate = Date.dateWithISO8601WithMillisecondsString(lastUpdatedString) else { + throw Error.incorrectLastUpdatedDateFormat + } + + state = rewindStateEnum + lastUpdated = lastUpdatedDate + reason = dictionary["reason"] as? String + if let rawRestore = dictionary["rewind"] as? [String: AnyObject] { + restore = try RestoreStatus(dictionary: rawRestore) + } else { + restore = nil + } + } +} + +public extension RewindStatus { + enum State: String { + case active + case inactive + case unavailable + case awaitingCredentials = "awaiting_credentials" + case provisioning + } +} + +private extension RewindStatus { + enum Error: Swift.Error { + case missingState + case missingLastUpdatedDate + case incorrectLastUpdatedDateFormat + case invalidRewindState + } +} + +public class RestoreStatus { + public let id: String + public let status: Status + public let progress: Int + public let message: String? + public let currentEntry: String? + public let errorCode: String? + public let failureReason: String? + + init(dictionary: [String: AnyObject]) throws { + guard let restoreId = dictionary["rewind_id"] as? String else { + throw Error.missingRestoreId + } + guard let restoreStatus = dictionary["status"] as? String else { + throw Error.missingRestoreStatus + } + guard let restoreStatusEnum = Status(rawValue: restoreStatus) else { + throw Error.invalidRestoreStatus + } + + id = restoreId + status = restoreStatusEnum + progress = dictionary["progress"] as? Int ?? 0 + message = dictionary["message"] as? String + currentEntry = dictionary["current_entry"] as? String + errorCode = dictionary["error_code"] as? String + failureReason = dictionary["reason"] as? String + } +} + +public extension RestoreStatus { + @frozen enum Status: String { + case queued + case finished + case running + case fail + } +} + +extension RestoreStatus { + enum Error: Swift.Error { + case missingRestoreId + case missingRestoreStatus + case invalidRestoreStatus + } +} diff --git a/Modules/Sources/WordPressKit/ActivityServiceRemote.swift b/Modules/Sources/WordPressKit/ActivityServiceRemote.swift new file mode 100644 index 000000000000..5f4d170c25de --- /dev/null +++ b/Modules/Sources/WordPressKit/ActivityServiceRemote.swift @@ -0,0 +1,233 @@ +import Foundation +import WordPressKitObjC + +open class ActivityServiceRemote: ServiceRemoteWordPressComREST { + + public enum ResponseError: Error { + case decodingFailure + } + + private lazy var formatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = ("yyyy-MM-dd HH:mm:ss") + formatter.timeZone = NSTimeZone(forSecondsFromGMT: 0) as TimeZone + return formatter + }() + + /// Retrieves activity events associated to a site. + /// + /// - Parameters: + /// - siteID: The target site's ID. + /// - offset: The first N activities to be skipped in the returned array. + /// - count: Number of objects to retrieve. + /// - after: Only activies after the given Date will be returned + /// - before: Only activies before the given Date will be returned + /// - group: Array of strings of activity types, eg. post, attachment, user + /// - success: Closure to be executed on success + /// - failure: Closure to be executed on error. + /// + /// - Returns: An array of activities and a boolean indicating if there's more activities to fetch. + /// + open func getActivityForSite( + _ siteID: Int, + offset: Int = 0, + count: Int, + after: Date? = nil, + before: Date? = nil, + group: [String] = [], + rewindable: Bool? = nil, + searchText: String? = nil, + success: @escaping (_ activities: [Activity], _ hasMore: Bool) -> Void, + failure: @escaping (Error) -> Void + ) { + var path = URLComponents(string: "sites/\(siteID)/activity") + if rewindable == true, let currentPath = path?.path { + path?.path = currentPath.appending("/rewindable") + } + + path?.queryItems = group.map { URLQueryItem(name: "group[]", value: $0) } + + let pageNumber = (offset / count) + 1 + path?.queryItems?.append(URLQueryItem(name: "number", value: "\(count)")) + path?.queryItems?.append(URLQueryItem(name: "page", value: "\(pageNumber)")) + + if let after, let before, + let lastSecondOfBeforeDay = before.endOfDay() { + path?.queryItems?.append(URLQueryItem(name: "after", value: formatter.string(from: after))) + path?.queryItems?.append(URLQueryItem(name: "before", value: formatter.string(from: lastSecondOfBeforeDay))) + } else if let on = after ?? before { + path?.queryItems?.append(URLQueryItem(name: "on", value: formatter.string(from: on))) + } + if let searchText, !searchText.isEmpty { + path?.queryItems?.append(URLQueryItem(name: "text_search", value: searchText)) + } + + guard let endpoint = path?.string else { + return + } + + let finalPath = self.path(forEndpoint: endpoint, withVersion: ._2_0) + + wordPressComRESTAPI.get(finalPath, + parameters: nil, + success: { response, _ in + do { + let (activities, totalItems) = try self.mapActivitiesResponse(response) + let hasMore = totalItems > pageNumber * (count + 1) + success(activities, hasMore) + } catch { + WPKitLogError("Error parsing activity response for site \(siteID)") + WPKitLogError("\(error)") + WPKitLogDebug("Full response: \(response)") + failure(error) + } + }, failure: { error, _ in + failure(error) + }) + } + + /// Retrieves activity groups associated with a site. + /// + /// - Parameters: + /// - siteID: The target site's ID. + /// - after: Only activity groups after the given Date will be returned. + /// - before: Only activity groups before the given Date will be returned. + /// - success: Closure to be executed on success. + /// - failure: Closure to be executed on error. + /// + /// - Returns: An array of available activity groups for a site. + /// + open func getActivityGroupsForSite(_ siteID: Int, + after: Date? = nil, + before: Date? = nil, + success: @escaping (_ groups: [ActivityGroup]) -> Void, + failure: @escaping (Error) -> Void) { + let endpoint = "sites/\(siteID)/activity/count/group" + let path = self.path(forEndpoint: endpoint, withVersion: ._2_0) + var parameters: [String: AnyObject] = [:] + + if let after, let before, + let lastSecondOfBeforeDay = before.endOfDay() { + parameters["after"] = formatter.string(from: after) as AnyObject + parameters["before"] = formatter.string(from: lastSecondOfBeforeDay) as AnyObject + } else if let on = after ?? before { + parameters["on"] = formatter.string(from: on) as AnyObject + } + + wordPressComRESTAPI.get(path, + parameters: parameters, + success: { response, _ in + do { + let groups = try self.mapActivityGroupsResponse(response) + success(groups) + } catch { + WPKitLogError("Error parsing activity groups for site \(siteID)") + WPKitLogError("\(error)") + WPKitLogDebug("Full response: \(response)") + failure(error) + } + }, failure: { error, _ in + failure(error) + }) + } + + /// Retrieves the site current rewind state. + /// + /// - Parameters: + /// - siteID: The target site's ID. + /// + /// - Returns: The current rewind status for the site. + /// + open func getRewindStatus(_ siteID: Int, + success: @escaping (RewindStatus) -> Void, + failure: @escaping (Error) -> Void) { + let endpoint = "sites/\(siteID)/rewind" + let path = self.path(forEndpoint: endpoint, withVersion: ._2_0) + + wordPressComRESTAPI.get(path, + parameters: nil, + success: { response, _ in + guard let rewindStatus = response as? [String: AnyObject] else { + failure(ResponseError.decodingFailure) + return + } + do { + let status = try RewindStatus(dictionary: rewindStatus) + success(status) + } catch { + WPKitLogError("Error parsing rewind response for site \(siteID)") + WPKitLogError("\(error)") + WPKitLogDebug("Full response: \(response)") + failure(ResponseError.decodingFailure) + } + }, failure: { error, _ in + // FIXME: A hack to support free WPCom sites and Rewind. Should be obsolote as soon as the backend + // stops returning 412's for those sites. + let nsError = error as NSError + + guard nsError.domain == WordPressComRestApiEndpointError.errorDomain, + nsError.code == WordPressComRestApiErrorCode.preconditionFailure.rawValue else { + failure(error) + return + } + + let status = RewindStatus(state: .unavailable) + success(status) + return + }) + } + +} + +private extension ActivityServiceRemote { + + func mapActivitiesResponse(_ response: Any) throws -> ([Activity], Int) { + + guard let json = response as? [String: AnyObject], + let totalItems = json["totalItems"] as? Int else { + throw ActivityServiceRemote.ResponseError.decodingFailure + } + + guard totalItems > 0 else { + return ([], 0) + } + + guard let current = json["current"] as? [String: AnyObject], + let orderedItems = current["orderedItems"] as? [[String: AnyObject]] else { + throw ActivityServiceRemote.ResponseError.decodingFailure + } + + do { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .supportMultipleDateFormats + let data = try JSONSerialization.data(withJSONObject: orderedItems, options: []) + let activities = try decoder.decode([Activity].self, from: data) + + return (activities, totalItems) + + } catch { + throw ActivityServiceRemote.ResponseError.decodingFailure + } + } + + func mapActivityGroupsResponse(_ response: Any) throws -> ([ActivityGroup]) { + guard let json = response as? [String: AnyObject], + let totalItems = json["totalItems"] as? Int, totalItems > 0 else { + return [] + } + + guard let rawGroups = json["groups"] as? [String: AnyObject] else { + throw ActivityServiceRemote.ResponseError.decodingFailure + } + + let groups: [ActivityGroup] = try rawGroups.map { (key, value) -> ActivityGroup in + guard let group = value as? [String: AnyObject] else { + throw ActivityServiceRemote.ResponseError.decodingFailure + } + return try ActivityGroup(key, dictionary: group) + } + + return groups + } + +} diff --git a/Modules/Sources/WordPressKit/ActivityServiceRemote_ApiVersion1_0.swift b/Modules/Sources/WordPressKit/ActivityServiceRemote_ApiVersion1_0.swift new file mode 100644 index 000000000000..5d3680f86e18 --- /dev/null +++ b/Modules/Sources/WordPressKit/ActivityServiceRemote_ApiVersion1_0.swift @@ -0,0 +1,49 @@ +import Foundation +import WordPressKitObjC + +@objc public class ActivityServiceRemote_ApiVersion1_0: ServiceRemoteWordPressComREST { + + public enum ResponseError: Error { + case decodingFailure + } + + /// Makes a request to Restore a site to a previous state. + /// + /// - Parameters: + /// - siteID: The target site's ID. + /// - rewindID: The rewindID to restore to. + /// - types: The types of items to restore. + /// - success: Closure to be executed on success + /// - failure: Closure to be executed on error. + /// + /// - Returns: A restoreID and jobID to check the status of the rewind request. + /// + public func restoreSite(_ siteID: Int, + rewindID: String, + types: JetpackRestoreTypes? = nil, + success: @escaping (_ restoreID: String, _ jobID: Int) -> Void, + failure: @escaping (Error) -> Void) { + let endpoint = "activity-log/\(siteID)/rewind/to/\(rewindID)" + let path = self.path(forEndpoint: endpoint, withVersion: ._1_0) + var parameters: [String: AnyObject] = [:] + + if let types { + parameters["types"] = types.toDictionary() as AnyObject + } + + wordPressComRESTAPI.post(path, + parameters: parameters, + success: { response, _ in + guard let responseDict = response as? [String: Any], + let restoreID = responseDict["restore_id"] as? Int, + let jobID = responseDict["job_id"] as? Int else { + failure(ResponseError.decodingFailure) + return + } + success(String(restoreID), jobID) + }, + failure: { error, _ in + failure(error) + }) + } +} diff --git a/Modules/Sources/WordPressKit/AnnouncementServiceRemote.swift b/Modules/Sources/WordPressKit/AnnouncementServiceRemote.swift new file mode 100644 index 000000000000..1afc2cbd1947 --- /dev/null +++ b/Modules/Sources/WordPressKit/AnnouncementServiceRemote.swift @@ -0,0 +1,124 @@ +import Foundation +import WordPressKitObjC + +/// Retrieves feature announcements from the related endpoint +open class AnnouncementServiceRemote: ServiceRemoteWordPressComREST { + + open func getAnnouncements(appId: String, + appVersion: String, + locale: String, + completion: @escaping (Result<[Announcement], Error>) -> Void) { + + guard let endPoint = makeEndpoint(appId: appId, appVersion: appVersion, locale: locale) else { + completion(.failure(AnnouncementError.endpointError)) + return + } + + let path = self.path(forEndpoint: endPoint, withVersion: ._2_0) + Task { @MainActor [wordPressComRestApi] in + await wordPressComRestApi.perform(.get, URLString: path, type: AnnouncementsContainer.self) + .map { $0.body.announcements } + .eraseToError() + .execute(completion) + } + } +} + +// MARK: - Helpers +private extension AnnouncementServiceRemote { + + func makeQueryItems(appId: String, appVersion: String, locale: String) -> [URLQueryItem] { + return [URLQueryItem(name: Constants.appIdKey, value: appId), + URLQueryItem(name: Constants.appVersionKey, value: appVersion), + URLQueryItem(name: Constants.localeKey, value: locale)] + } + + func makeEndpoint(appId: String, appVersion: String, locale: String) -> String? { + var path = URLComponents(string: Constants.baseUrl) + path?.queryItems = makeQueryItems(appId: appId, appVersion: appVersion, locale: locale) + return path?.string + } +} + +// MARK: - Constants +private extension AnnouncementServiceRemote { + + enum Constants { + static let baseUrl = "mobile/feature-announcements/" + static let appIdKey = "app_id" + static let appVersionKey = "app_version" + static let localeKey = "_locale" + } + + enum AnnouncementError: Error { + case endpointError + + var localizedDescription: String { + switch self { + case .endpointError: + return NSLocalizedString("Invalid endpoint", + comment: "Error message generated when announcement service is unable to return a valid endpoint.") + } + } + } +} + +// MARK: - Decoded data +public struct AnnouncementsContainer: Decodable { + public let announcements: [Announcement] + + private enum CodingKeys: String, CodingKey { + case announcements = "announcements" + } + + public init(from decoder: Decoder) throws { + let rootContainer = try decoder.container(keyedBy: CodingKeys.self) + announcements = try rootContainer.decode([Announcement].self, forKey: .announcements) + } +} + +public struct Announcement: Codable { + public let appVersionName: String + public let minimumAppVersion: String + public let maximumAppVersion: String + public let appVersionTargets: [String] + public let detailsUrl: String + public let announcementVersion: String + public let isLocalized: Bool + public let responseLocale: String + public let features: [Feature] + + public init(appVersionName: String, minimumAppVersion: String, maximumAppVersion: String, appVersionTargets: [String], detailsUrl: String, announcementVersion: String, isLocalized: Bool, responseLocale: String, features: [Feature]) { + self.appVersionName = appVersionName + self.minimumAppVersion = minimumAppVersion + self.maximumAppVersion = maximumAppVersion + self.appVersionTargets = appVersionTargets + self.detailsUrl = detailsUrl + self.announcementVersion = announcementVersion + self.isLocalized = isLocalized + self.responseLocale = responseLocale + self.features = features + } +} + +public struct Feature: Codable { + public let title: String + public let subtitle: String + public let icons: [FeatureIcon]? + public let iconUrl: String + public let iconBase64: String? + + public init(title: String, subtitle: String, icons: [FeatureIcon]?, iconUrl: String, iconBase64: String?) { + self.title = title + self.subtitle = subtitle + self.icons = icons + self.iconUrl = iconUrl + self.iconBase64 = iconBase64 + } +} + +public struct FeatureIcon: Codable { + public let iconUrl: String + public let iconBase64: String + public let iconType: String +} diff --git a/Modules/Sources/WordPressKit/AppTransportSecuritySettings.swift b/Modules/Sources/WordPressKit/AppTransportSecuritySettings.swift new file mode 100644 index 000000000000..25354d513412 --- /dev/null +++ b/Modules/Sources/WordPressKit/AppTransportSecuritySettings.swift @@ -0,0 +1,75 @@ +import Foundation + +/// A dependency of `AppTransportSecuritySettings` generally used for injection in unit tests. +/// +/// Only `Bundle` would conform to this `protocol`. +protocol InfoDictionaryObjectProvider { + func object(forInfoDictionaryKey key: String) -> Any? +} + +extension Bundle: InfoDictionaryObjectProvider { + +} + +/// Provides a simpler interface to the `Bundle` (`Info.plist`) settings under the +/// `NSAppTransportSecurity` key. +struct AppTransportSecuritySettings { + + private let infoDictionaryObjectProvider: InfoDictionaryObjectProvider + + private var settings: NSDictionary? { + infoDictionaryObjectProvider.object(forInfoDictionaryKey: "NSAppTransportSecurity") as? NSDictionary + } + + private var exceptionDomains: NSDictionary? { + settings?["NSExceptionDomains"] as? NSDictionary + } + + init(_ infoDictionaryObjectProvider: InfoDictionaryObjectProvider = Bundle.main) { + self.infoDictionaryObjectProvider = infoDictionaryObjectProvider + } + + /// Returns whether the `NSAppTransportSecurity` settings indicate that access to the + /// given `siteURL` should be through SSL/TLS only. + /// + /// Secure access is the default that is set by Apple. But the hosting app is allowed to + /// override this for specific or for all domains. This method encapsulates the logic for + /// reading the `Bundle` (`Info.plist`) settings and translating the rules and conditions + /// described in the + /// [NSAppTransportSecurity](https://developer.apple.com/documentation/bundleresources/information_property_list/nsapptransportsecurity) + /// documentation and its sub-pages. + func secureAccessOnly(for siteURL: URL) -> Bool { + // From Apple: If you specify an exception domain dictionary, ATS ignores any global + // configuration keys, like NSAllowsArbitraryLoads, for that domain. This is true even + // if you leave the domain-specific dictionary empty and rely entirely on its keys’ default + // values. + if let exceptionDomain = self.exceptionDomain(for: siteURL) { + let allowsInsecureHTTPLoads = + exceptionDomain["NSExceptionAllowsInsecureHTTPLoads"] as? Bool ?? false + return !allowsInsecureHTTPLoads + } + + guard let settings else { + return true + } + + // From Apple: The value of the `NSAllowsArbitraryLoads` key is ignored—and the default value of + // NO used instead—if any of the following keys are present: + guard settings["NSAllowsLocalNetworking"] == nil && + settings["NSAllowsArbitraryLoadsForMedia"] == nil && + settings["NSAllowsArbitraryLoadsInWebContent"] == nil else { + return true + } + + let allowsArbitraryLoads = settings["NSAllowsArbitraryLoads"] as? Bool ?? false + return !allowsArbitraryLoads + } + + private func exceptionDomain(for siteURL: URL) -> NSDictionary? { + guard let domain = siteURL.host?.lowercased() else { + return nil + } + + return exceptionDomains?[domain] as? NSDictionary + } +} diff --git a/Modules/Sources/WordPressKit/AtomicAuthenticationServiceRemote.swift b/Modules/Sources/WordPressKit/AtomicAuthenticationServiceRemote.swift new file mode 100644 index 000000000000..23fc6b860db9 --- /dev/null +++ b/Modules/Sources/WordPressKit/AtomicAuthenticationServiceRemote.swift @@ -0,0 +1,83 @@ +import Foundation +import WordPressKitObjC + +public class AtomicAuthenticationServiceRemote: ServiceRemoteWordPressComREST { + + public enum ResponseError: Error { + case responseIsNotADictionary(response: Any) + case decodingFailure(response: [String: AnyObject]) + case couldNotInstantiateCookie(name: String, value: String, domain: String, path: String, expires: Date) + } + + public func getAuthCookie( + siteID: Int, + success: @escaping (_ cookie: HTTPCookie) -> Void, + failure: @escaping (Error) -> Void) { + + let endpoint = "sites/\(siteID)/atomic-auth-proxy/read-access-cookies" + let path = self.path(forEndpoint: endpoint, withVersion: ._2_0) + + wordPressComRESTAPI.get(path, + parameters: nil, + success: { responseObject, _ in + do { + let settings = try self.cookie(from: responseObject) + success(settings) + } catch { + failure(error) + } + }, + failure: { error, _ in + failure(error) + }) + } + + // MARK: - Result Parsing + + private func date(from expiration: Int) -> Date { + return Date(timeIntervalSince1970: TimeInterval(expiration)) + } + + private func cookie(from responseObject: Any) throws -> HTTPCookie { + guard let response = responseObject as? [String: AnyObject] else { + let error = ResponseError.responseIsNotADictionary(response: responseObject) + WPKitLogError("❗️Error: \(error)") + throw error + } + + guard let cookies = response["cookies"] as? [[String: Any]] else { + let error = ResponseError.decodingFailure(response: response) + WPKitLogError("❗️Error: \(error)") + throw error + } + + let cookieDictionary = cookies[0] + + guard let name = cookieDictionary["name"] as? String, + let value = cookieDictionary["value"] as? String, + let domain = cookieDictionary["domain"] as? String, + let path = cookieDictionary["path"] as? String, + let expires = cookieDictionary["expires"] as? Int else { + + let error = ResponseError.decodingFailure(response: response) + WPKitLogError("❗️Error: \(error)") + throw error + } + + let expirationDate = date(from: expires) + + guard let cookie = HTTPCookie(properties: [ + .name: name, + .value: value, + .domain: domain, + .path: path, + .expires: expirationDate + ]) else { + let error = ResponseError.couldNotInstantiateCookie(name: name, value: value, domain: domain, path: path, expires: expirationDate) + WPKitLogError("❗️Error: \(error)") + throw error + } + + return cookie + } +} diff --git a/Modules/Sources/WordPressKit/AtomicLogs.swift b/Modules/Sources/WordPressKit/AtomicLogs.swift new file mode 100644 index 000000000000..c0fcb7e31f33 --- /dev/null +++ b/Modules/Sources/WordPressKit/AtomicLogs.swift @@ -0,0 +1,47 @@ +import Foundation + +public final class AtomicErrorLogEntry: Decodable { + public let message: String? + public let severity: String? + public let kind: String? + public let name: String? + public let file: String? + public let line: Int? + public let timestamp: Date? + + @frozen public enum Severity: String { + case user = "User" + case warning = "Warning" + case deprecated = "Deprecated" + case fatalError = "Fatal error" + } +} + +public final class AtomicErrorLogsResponse: Decodable { + public let totalResults: Int + public let logs: [AtomicErrorLogEntry] + public let scrollId: String? +} + +public class AtomicWebServerLogEntry: Decodable { + public let bodyBytesSent: Int? + /// The possible values are `"true"` or `"false"`. + public let cached: String? + public let date: Date? + public let httpHost: String? + public let httpReferer: String? + public let httpUserAgent: String? + public let requestTime: Double? + public let requestType: String? + public let requestUrl: String? + public let scheme: String? + public let status: Int? + public let timestamp: Int? + public let type: String? +} + +public final class AtomicWebServerLogsResponse: Decodable { + public let totalResults: Int + public let logs: [AtomicWebServerLogEntry] + public let scrollId: String? +} diff --git a/Modules/Sources/WordPressKit/AtomicSiteServiceRemote.swift b/Modules/Sources/WordPressKit/AtomicSiteServiceRemote.swift new file mode 100644 index 000000000000..a574c86ea973 --- /dev/null +++ b/Modules/Sources/WordPressKit/AtomicSiteServiceRemote.swift @@ -0,0 +1,90 @@ +import Foundation +import WordPressKitObjC + +public final class AtomicSiteServiceRemote: ServiceRemoteWordPressComREST { + /// - parameter scrollID: Pass the scroll ID from the previous response to + /// fetch the next page. + public func getErrorLogs(siteID: Int, + range: Range, + severity: AtomicErrorLogEntry.Severity? = nil, + scrollID: String? = nil, + pageSize: Int = 50, + success: @escaping (AtomicErrorLogsResponse) -> Void, + failure: @escaping (Error) -> Void) { + let path = self.path(forEndpoint: "sites/\(siteID)/hosting/error-logs/", withVersion: ._2_0) + var parameters = [ + "start": "\(Int(range.lowerBound.timeIntervalSince1970))", + "end": "\(Int(range.upperBound.timeIntervalSince1970))", + "sort_order": "desc", + "page_size": "\(pageSize)" + ] as [String: String] + if let severity { + parameters["filter[severity][]"] = severity.rawValue + } + if let scrollID { + parameters["scroll_id"] = scrollID + } + wordPressComRESTAPI.get(path, parameters: parameters as [String: AnyObject]) { responseObject, httpResponse in + guard (200..<300).contains(httpResponse?.statusCode ?? 0), + let data = (responseObject as? [String: AnyObject])?["data"], + JSONSerialization.isValidJSONObject(data) else { + failure(URLError(.unknown)) + return + } + do { + let data = try JSONSerialization.data(withJSONObject: data) + let response = try JSONDecoder.apiDecoder.decode(AtomicErrorLogsResponse.self, from: data) + success(response) + } catch { + WPKitLogError("Error parsing campaigns response: \(error), \(responseObject)") + failure(error) + } + } failure: { error, _ in + failure(error) + } + } + + public func getWebServerLogs(siteID: Int, + range: Range, + httpMethod: String? = nil, + statusCode: Int? = nil, + scrollID: String? = nil, + pageSize: Int = 50, + success: @escaping (AtomicWebServerLogsResponse) -> Void, + failure: @escaping (Error) -> Void) { + let path = self.path(forEndpoint: "sites/\(siteID)/hosting/logs/", withVersion: ._2_0) + var parameters = [ + "start": "\(Int(range.lowerBound.timeIntervalSince1970))", + "end": "\(Int(range.upperBound.timeIntervalSince1970))", + "sort_order": "desc", + "page_size": "\(pageSize)" + ] as [String: String] + if let httpMethod { + parameters["filter[request_type][]"] = httpMethod.uppercased() + } + if let statusCode { + parameters["filter[status][]"] = "\(statusCode)" + } + if let scrollID { + parameters["scroll_id"] = scrollID + } + wordPressComRESTAPI.get(path, parameters: parameters as [String: AnyObject]) { responseObject, httpResponse in + guard (200..<300).contains(httpResponse?.statusCode ?? 0), + let data = (responseObject as? [String: AnyObject])?["data"], + JSONSerialization.isValidJSONObject(data) else { + failure(URLError(.unknown)) + return + } + do { + let data = try JSONSerialization.data(withJSONObject: data) + let response = try JSONDecoder.apiDecoder.decode(AtomicWebServerLogsResponse.self, from: data) + success(response) + } catch { + WPKitLogError("Error parsing campaigns response: \(error), \(responseObject)") + failure(error) + } + } failure: { error, _ in + failure(error) + } + } +} diff --git a/Modules/Sources/WordPressKit/AutomatedTransferService.swift b/Modules/Sources/WordPressKit/AutomatedTransferService.swift new file mode 100644 index 000000000000..362648b712d2 --- /dev/null +++ b/Modules/Sources/WordPressKit/AutomatedTransferService.swift @@ -0,0 +1,137 @@ +import Foundation +import WordPressKitObjC + +/// Class encapsualting all requests related to performing Automated Transfer operations. +public class AutomatedTransferService: ServiceRemoteWordPressComREST { + + @frozen public enum ResponseError: Error { + case decodingFailure + } + + @frozen public enum AutomatedTransferEligibilityError: Error { + case unverifiedEmail + case excessiveDiskSpaceUsage + case noBusinessPlan + case VIPSite + case notAdmin + case notDomainOwner + case noCustomDomain + case greylistedSite + case privateSite + case unknown + } + + public func checkTransferEligibility(siteID: Int, + success: @escaping () -> Void, + failure: @escaping (AutomatedTransferEligibilityError) -> Void) { + let endpoint = "sites/\(siteID)/automated-transfers/eligibility" + let path = self.path(forEndpoint: endpoint, withVersion: ._1_1) + + wordPressComRESTAPI.get(path, parameters: nil, success: { (responseObject, _) in + guard let response = responseObject as? [String: AnyObject] else { + failure(.unknown) + return + } + + guard let isEligible = response["is_eligible"] as? Bool, isEligible == true else { + failure(self.eligibilityError(from: response)) + return + } + + success() + }, failure: { _, _ in + failure(.unknown) + }) + } + + public typealias AutomatedTransferInitationResponse = (transferID: Int, status: AutomatedTransferStatus) + public func initiateAutomatedTransfer(siteID: Int, + pluginSlug: String, + success: @escaping (AutomatedTransferInitationResponse) -> Void, + failure: @escaping (Error) -> Void) { + + let endpoint = "sites/\(siteID)/automated-transfers/initiate" + let path = self.path(forEndpoint: endpoint, withVersion: ._1_1) + let payload = ["plugin": pluginSlug] as [String: AnyObject] + + wordPressComRESTAPI.post(path, parameters: payload, success: { (responseObject, _) in + guard let response = responseObject as? [String: AnyObject] else { + failure(ResponseError.decodingFailure) + return + } + + guard let transferID = response["transfer_id"] as? Int, + let status = response["status"] as? String, + let statusObject = AutomatedTransferStatus(status: status) else { + failure(ResponseError.decodingFailure) + return + } + + success((transferID: transferID, status: statusObject)) + }) { (error, _) in + failure(error) + } + + } + + public func fetchAutomatedTransferStatus(siteID: Int, + success: @escaping (AutomatedTransferStatus) -> Void, + failure: @escaping (Error) -> Void) { + + let endpoint = "sites/\(siteID)/automated-transfers/status" + let path = self.path(forEndpoint: endpoint, withVersion: ._1_1) + + wordPressComRESTAPI.get(path, parameters: nil, success: { (responseObject, _) in + guard let response = responseObject as? [String: AnyObject] else { + failure(ResponseError.decodingFailure) + return + } + + guard let status = response["status"] as? String, + let currentStep = response["step"] as? Int, + let totalSteps = response["total"] as? Int, + let statusObject = AutomatedTransferStatus(status: status, step: currentStep, totalSteps: totalSteps) else { + failure(ResponseError.decodingFailure) + return + } + + success(statusObject) + }) { (error, _) in + failure(error) + } + + } + + private func eligibilityError(from response: [String: AnyObject]) -> AutomatedTransferEligibilityError { + guard let errors = response["errors"] as? [[String: AnyObject]], + let errorType = errors.first?["code"] as? String else { + // The API can potentially return multiple errors here. Since there isn't really an actionable + // way for user to deal with multiple of them at once, we're just picking the first one. + return .unknown + } + + switch errorType { + case "email_unverified": + return .unverifiedEmail + case "excessive_disk_space": + return .excessiveDiskSpaceUsage + case "no_business_plan": + return .noBusinessPlan + case "no_vip_sites": + return .VIPSite + case "non_admin_user": + return .notAdmin + case "not_domain_owner": + return .notDomainOwner + case "not_using_custom_domain": + return .noCustomDomain + case "site_graylisted": + return .greylistedSite + case "site_private": + return .privateSite + default: + return .unknown + } + } + +} diff --git a/Modules/Sources/WordPressKit/AutomatedTransferStatus.swift b/Modules/Sources/WordPressKit/AutomatedTransferStatus.swift new file mode 100644 index 000000000000..e4d5c1c9e006 --- /dev/null +++ b/Modules/Sources/WordPressKit/AutomatedTransferStatus.swift @@ -0,0 +1,40 @@ +import Foundation + +/// A helper object encapsulating a status of Automated Transfer operation. +public struct AutomatedTransferStatus { + public enum State: String, RawRepresentable { + case active + case backfilling + case complete + case error + case notFound = "not found" + case unknownStatus = "unknown_status" + case uploading + case pending + } + + public let status: State + public let step: Int? + public let totalSteps: Int? + + init?(status statusString: String) { + guard let status = State(rawValue: statusString) else { + return nil + } + + self.status = status + self.step = nil + self.totalSteps = nil + } + + init?(status statusString: String, step: Int, totalSteps: Int) { + guard let status = State(rawValue: statusString) else { + return nil + } + + self.status = status + self.step = step + self.totalSteps = totalSteps + } + +} diff --git a/Modules/Sources/WordPressKit/BlazeCampaign.swift b/Modules/Sources/WordPressKit/BlazeCampaign.swift new file mode 100644 index 000000000000..5ab9af9888e3 --- /dev/null +++ b/Modules/Sources/WordPressKit/BlazeCampaign.swift @@ -0,0 +1,94 @@ +import Foundation + +public final class BlazeCampaign: Codable { + public let campaignID: Int + public let name: String? + public let startDate: Date? + public let endDate: Date? + /// A raw campaign status on the server. + public let status: Status + /// A subset of ``BlazeCampaign/status-swift.property`` values where some + /// cases are skipped for simplicity and mapped to other more common ones. + public let uiStatus: Status + public let budgetCents: Int? + public let targetURL: String? + public let stats: Stats? + public let contentConfig: ContentConfig? + public let creativeHTML: String? + + public init(campaignID: Int, name: String?, startDate: Date?, endDate: Date?, status: Status, uiStatus: Status, budgetCents: Int?, targetURL: String?, stats: Stats?, contentConfig: ContentConfig?, creativeHTML: String?) { + self.campaignID = campaignID + self.name = name + self.startDate = startDate + self.endDate = endDate + self.status = status + self.uiStatus = uiStatus + self.budgetCents = budgetCents + self.targetURL = targetURL + self.stats = stats + self.contentConfig = contentConfig + self.creativeHTML = creativeHTML + } + + enum CodingKeys: String, CodingKey { + case campaignID = "campaignId" + case name + case startDate + case endDate + case status + case uiStatus + case budgetCents + case targetURL = "targetUrl" + case contentConfig + case stats = "campaignStats" + case creativeHTML = "creativeHtml" + } + + @frozen public enum Status: String, Codable { + case scheduled + case created + case rejected + case approved + case active + case canceled + case finished + case processing + case unknown + + public init(from decoder: Decoder) throws { + let status = try? String(from: decoder) + self = status.flatMap(Status.init) ?? .unknown + } + } + + public struct Stats: Codable { + public let impressionsTotal: Int? + public let clicksTotal: Int? + + public init(impressionsTotal: Int?, clicksTotal: Int?) { + self.impressionsTotal = impressionsTotal + self.clicksTotal = clicksTotal + } + } + + public struct ContentConfig: Codable { + public let title: String? + public let snippet: String? + public let clickURL: String? + public let imageURL: String? + + public init(title: String?, snippet: String?, clickURL: String?, imageURL: String?) { + self.title = title + self.snippet = snippet + self.clickURL = clickURL + self.imageURL = imageURL + } + + enum CodingKeys: String, CodingKey { + case title + case snippet + case clickURL = "clickUrl" + case imageURL = "imageUrl" + } + } +} diff --git a/Modules/Sources/WordPressKit/BlazeCampaignsSearchResponse.swift b/Modules/Sources/WordPressKit/BlazeCampaignsSearchResponse.swift new file mode 100644 index 000000000000..031652004c33 --- /dev/null +++ b/Modules/Sources/WordPressKit/BlazeCampaignsSearchResponse.swift @@ -0,0 +1,15 @@ +import Foundation + +public final class BlazeCampaignsSearchResponse: Decodable { + public let campaigns: [BlazeCampaign]? + public let totalItems: Int? + public let totalPages: Int? + public let page: Int? + + public init(totalItems: Int?, campaigns: [BlazeCampaign]?, totalPages: Int?, page: Int?) { + self.totalItems = totalItems + self.campaigns = campaigns + self.totalPages = totalPages + self.page = page + } +} diff --git a/Modules/Sources/WordPressKit/BlazeServiceRemote.swift b/Modules/Sources/WordPressKit/BlazeServiceRemote.swift new file mode 100644 index 000000000000..ef98d15638ca --- /dev/null +++ b/Modules/Sources/WordPressKit/BlazeServiceRemote.swift @@ -0,0 +1,30 @@ +import Foundation +import WordPressKitObjC + +open class BlazeServiceRemote: ServiceRemoteWordPressComREST { + + // MARK: - Campaigns + + /// Searches the campaigns for the site with the given ID. The campaigns are returned ordered by the post date. + /// + /// - parameters: + /// - siteId: The site ID. + /// - page: The response page. By default, returns the first page. + open func searchCampaigns(forSiteId siteId: Int, page: Int = 1, callback: @escaping (Result) -> Void) { + let endpoint = "sites/\(siteId)/wordads/dsp/api/v1/search/campaigns/site/\(siteId)" + let path = self.path(forEndpoint: endpoint, withVersion: ._2_0) + Task { @MainActor in + let result = await self.wordPressComRestApi + .perform( + .get, + URLString: path, + parameters: ["page": page] as [String: AnyObject], + jsonDecoder: JSONDecoder.apiDecoder, + type: BlazeCampaignsSearchResponse.self + ) + .map { $0.body } + .eraseToError() + callback(result) + } + } +} diff --git a/Modules/Sources/WordPressKit/BlockEditorSettingsServiceRemote.swift b/Modules/Sources/WordPressKit/BlockEditorSettingsServiceRemote.swift new file mode 100644 index 000000000000..d8ddd37d6613 --- /dev/null +++ b/Modules/Sources/WordPressKit/BlockEditorSettingsServiceRemote.swift @@ -0,0 +1,45 @@ +import Foundation + +public class BlockEditorSettingsServiceRemote { + let remoteAPI: WordPressOrgRestApi + public init(remoteAPI: WordPressOrgRestApi) { + self.remoteAPI = remoteAPI + } +} + +// MARK: Editor `theme_supports` support +public extension BlockEditorSettingsServiceRemote { + typealias EditorThemeCompletionHandler = (Swift.Result) -> Void + + func fetchTheme(completion: @escaping EditorThemeCompletionHandler) { + let requestPath = "/wp/v2/themes" + let parameters = ["status": "active"] + Task { @MainActor in + let result = await self.remoteAPI.get(path: requestPath, parameters: parameters, type: [RemoteEditorTheme].self) + .map { $0.first } + .mapError { error -> Error in error } + completion(result) + } + } + +} + +// MARK: Editor Global Styles support +public extension BlockEditorSettingsServiceRemote { + typealias BlockEditorSettingsCompletionHandler = (Swift.Result) -> Void + + func fetchBlockEditorSettings(completion: @escaping BlockEditorSettingsCompletionHandler) { + Task { @MainActor in + let result = await self.remoteAPI.get(path: "/wp-block-editor/v1/settings", parameters: ["context": "mobile"], type: RemoteBlockEditorSettings.self) + .map { settings -> RemoteBlockEditorSettings? in settings } + .flatMapError { original in + if case let .unparsableResponse(response, _, underlyingError) = original, response?.statusCode == 200, underlyingError is DecodingError { + return .success(nil) + } + return .failure(original) + } + .mapError { error -> Error in error } + completion(result) + } + } +} diff --git a/Modules/Sources/WordPressKit/BlogJetpackSettingsServiceRemote.swift b/Modules/Sources/WordPressKit/BlogJetpackSettingsServiceRemote.swift new file mode 100644 index 000000000000..808380ccbbe7 --- /dev/null +++ b/Modules/Sources/WordPressKit/BlogJetpackSettingsServiceRemote.swift @@ -0,0 +1,260 @@ +import Foundation +import WordPressKitObjC + +public class BlogJetpackSettingsServiceRemote: ServiceRemoteWordPressComREST { + + public enum ResponseError: Error { + case decodingFailure + } + + /// Fetches the Jetpack settings for the specified site + /// + public func getJetpackSettingsForSite(_ siteID: Int, success: @escaping (RemoteBlogJetpackSettings) -> Void, failure: @escaping (Error) -> Void) { + + let endpoint = "jetpack-blogs/\(siteID)/rest-api" + let path = self.path(forEndpoint: endpoint, withVersion: ._1_1) + let parameters: [String: Any] = ["path": "/jetpack/v4/settings"] + + wordPressComRESTAPI.get(path, + parameters: parameters, + success: { response, _ in + guard let responseDict = response as? [String: Any], + let results = responseDict["data"] as? [String: AnyObject], + let remoteSettings = try? self.remoteJetpackSettingsFromDictionary(results) else { + failure(ResponseError.decodingFailure) + return + } + success(remoteSettings) + }, failure: { + error, _ in + failure(error) + }) + } + + /// Fetches the Jetpack Monitor settings for the specified site + /// + public func getJetpackMonitorSettingsForSite(_ siteID: Int, success: @escaping (RemoteBlogJetpackMonitorSettings) -> Void, failure: @escaping (Error) -> Void) { + + let endpoint = "jetpack-blogs/\(siteID)" + let path = self.path(forEndpoint: endpoint, withVersion: ._1_1) + + wordPressComRESTAPI.get(path, + parameters: nil, + success: { response, _ in + guard let responseDict = response as? [String: Any], + let results = responseDict["settings"] as? [String: AnyObject], + let remoteMonitorSettings = try? self.remoteJetpackMonitorSettingsFromDictionary(results) else { + failure(ResponseError.decodingFailure) + return + } + success(remoteMonitorSettings) + }, failure: { + error, _ in + failure(error) + }) + } + + /// Fetches the Jetpack Modules settings for the specified site + /// + public func getJetpackModulesSettingsForSite(_ siteID: Int, success: @escaping (RemoteBlogJetpackModulesSettings) -> Void, failure: @escaping (Error) -> Void) { + + let endpoint = "sites/\(siteID)/jetpack/modules" + let path = self.path(forEndpoint: endpoint, withVersion: ._1_1) + + wordPressComRESTAPI.get(path, + parameters: nil, + success: { response, _ in + guard let responseDict = response as? [String: Any], + let modules = responseDict["modules"] as? [[String: AnyObject]], + let remoteModulesSettings = try? self.remoteJetpackModulesSettingsFromArray(modules) else { + failure(ResponseError.decodingFailure) + return + } + success(remoteModulesSettings) + }, failure: { + error, _ in + failure(error) + }) + } + + /// Saves the Jetpack settings for the specified site + /// + public func updateJetpackSettingsForSite(_ siteID: Int, settings: RemoteBlogJetpackSettings, success: @escaping () -> Void, failure: @escaping (Error?) -> Void) { + + let dictionary = dictionaryFromJetpackSettings(settings) + guard let jSONData = try? JSONSerialization.data(withJSONObject: dictionary, options: []), + let jSONBody = String(data: jSONData, encoding: .ascii) else { + failure(nil) + return + } + + let endpoint = "jetpack-blogs/\(siteID)/rest-api" + let path = self.path(forEndpoint: endpoint, withVersion: ._1_1) + let parameters = ["path": "/jetpack/v4/settings", + "body": jSONBody, + "json": true] as [String: AnyObject] + + wordPressComRESTAPI.post(path, + parameters: parameters, + success: { + _, _ in + success() + }, failure: { + error, _ in + failure(error) + }) + } + + /// Saves the Jetpack Monitor settings for the specified site + /// + public func updateJetpackMonitorSettingsForSite(_ siteID: Int, settings: RemoteBlogJetpackMonitorSettings, success: @escaping () -> Void, failure: @escaping (Error) -> Void) { + + let endpoint = "jetpack-blogs/\(siteID)" + let path = self.path(forEndpoint: endpoint, withVersion: ._1_1) + let parameters = dictionaryFromJetpackMonitorSettings(settings) + + wordPressComRESTAPI.post(path, + parameters: parameters, + success: { + _, _ in + success() + }, failure: { + error, _ in + failure(error) + }) + } + + /// Saves the Jetpack Module active setting for the specified site + /// + public func updateJetpackModuleActiveSettingForSite(_ siteID: Int, module: String, active: Bool, success: @escaping () -> Void, failure: @escaping (Error) -> Void) { + let endpoint = "sites/\(siteID)/jetpack/modules/\(module)" + let path = self.path(forEndpoint: endpoint, withVersion: ._1_1) + let parameters = [ModuleOptionKeys.active: active] + + wordPressComRESTAPI.post(path, + parameters: parameters as [String: AnyObject], + success: { + _, _ in + success() + }, failure: { + error, _ in + failure(error) + }) + } + + /// Disconnects Jetpack from a site + /// + @objc public func disconnectJetpackFromSite(_ siteID: Int, success: @escaping () -> Void, failure: @escaping (Error) -> Void) { + let endpoint = "jetpack-blogs/\(siteID)/mine/delete" + let path = self.path(forEndpoint: endpoint, withVersion: ._1_1) + + wordPressComRESTAPI.post(path, + parameters: nil, + success: { + _, _ in + success() + }, failure: { + error, _ in + failure(error) + }) + } + +} + +private extension BlogJetpackSettingsServiceRemote { + + func remoteJetpackSettingsFromDictionary(_ dictionary: [String: AnyObject]) throws -> RemoteBlogJetpackSettings { + + guard let monitorEnabled = dictionary[Keys.monitorEnabled] as? Bool, + let blockMaliciousLoginAttempts = dictionary[Keys.blockMaliciousLoginAttempts] as? Bool, + let allowlistedIPs = dictionary[Keys.allowListedIPAddresses]?[Keys.allowListedIPsLocal] as? [String], + let ssoEnabled = dictionary[Keys.ssoEnabled] as? Bool, + let ssoMatchAccountsByEmail = dictionary[Keys.ssoMatchAccountsByEmail] as? Bool, + let ssoRequireTwoStepAuthentication = dictionary[Keys.ssoRequireTwoStepAuthentication] as? Bool else { + throw ResponseError.decodingFailure + } + + return RemoteBlogJetpackSettings(monitorEnabled: monitorEnabled, + blockMaliciousLoginAttempts: blockMaliciousLoginAttempts, + loginAllowListedIPAddresses: Set(allowlistedIPs), + ssoEnabled: ssoEnabled, + ssoMatchAccountsByEmail: ssoMatchAccountsByEmail, + ssoRequireTwoStepAuthentication: ssoRequireTwoStepAuthentication) + } + + func remoteJetpackMonitorSettingsFromDictionary(_ dictionary: [String: AnyObject]) throws -> RemoteBlogJetpackMonitorSettings { + + guard let monitorEmailNotifications = dictionary[Keys.monitorEmailNotifications] as? Bool, + let monitorPushNotifications = dictionary[Keys.monitorPushNotifications] as? Bool else { + throw ResponseError.decodingFailure + } + + return RemoteBlogJetpackMonitorSettings(monitorEmailNotifications: monitorEmailNotifications, + monitorPushNotifications: monitorPushNotifications) + } + + func remoteJetpackModulesSettingsFromArray(_ modules: [[String: AnyObject]]) throws -> RemoteBlogJetpackModulesSettings { + let dictionary = modules.reduce(into: [String: [String: AnyObject]]()) { + guard let key = $1.valueAsString(forKey: "id") else { + return + } + $0[key] = $1 + } + + guard let serveImagesFromOurServersValue = dictionary[Keys.serveImagesFromOurServers]?[ModuleOptionKeys.active] as? Bool else { + throw ResponseError.decodingFailure + } + + return RemoteBlogJetpackModulesSettings(serveImagesFromOurServers: serveImagesFromOurServersValue) + } + + func dictionaryFromJetpackSettings(_ settings: RemoteBlogJetpackSettings) -> [String: Any] { + let joinedIPs = settings.loginAllowListedIPAddresses.joined(separator: ", ") + let shouldSendAllowlist = settings.blockMaliciousLoginAttempts + let settingsDictionary: [String: Any?] = [ + Keys.monitorEnabled: settings.monitorEnabled, + Keys.blockMaliciousLoginAttempts: settings.blockMaliciousLoginAttempts, + Keys.allowListedIPAddresses: shouldSendAllowlist ? joinedIPs : nil, + Keys.ssoEnabled: settings.ssoEnabled, + Keys.ssoMatchAccountsByEmail: settings.ssoMatchAccountsByEmail, + Keys.ssoRequireTwoStepAuthentication: settings.ssoRequireTwoStepAuthentication + ] + return settingsDictionary.compactMapValues { $0 } + } + + func dictionaryFromJetpackMonitorSettings(_ settings: RemoteBlogJetpackMonitorSettings) -> [String: AnyObject] { + + return [Keys.monitorEmailNotifications: settings.monitorEmailNotifications as AnyObject, + Keys.monitorPushNotifications: settings.monitorPushNotifications as AnyObject] + } +} + +public extension BlogJetpackSettingsServiceRemote { + + enum Keys { + + // RemoteBlogJetpackSettings keys + public static let monitorEnabled = "monitor" + public static let blockMaliciousLoginAttempts = "protect" + public static let allowListedIPAddresses = "jetpack_protect_global_whitelist" + public static let allowListedIPsLocal = "local" + public static let ssoEnabled = "sso" + public static let ssoMatchAccountsByEmail = "jetpack_sso_match_by_email" + public static let ssoRequireTwoStepAuthentication = "jetpack_sso_require_two_step" + + // RemoteBlogJetpackMonitorSettings keys + static let monitorEmailNotifications = "email_notifications" + static let monitorPushNotifications = "wp_note_notifications" + + // RemoteBlogJetpackModuleSettings keys + public static let serveImagesFromOurServers = "photon" + + } + + enum ModuleOptionKeys { + + // Whether or not the module is currently active + public static let active = "active" + + } +} diff --git a/Modules/Sources/WordPressKit/BloggingPromptsServiceRemote.swift b/Modules/Sources/WordPressKit/BloggingPromptsServiceRemote.swift new file mode 100644 index 000000000000..8968a26301f5 --- /dev/null +++ b/Modules/Sources/WordPressKit/BloggingPromptsServiceRemote.swift @@ -0,0 +1,154 @@ +import Foundation +import WordPressKitObjC + +/// Encapsulates logic to fetch blogging prompts from the remote endpoint. +/// +open class BloggingPromptsServiceRemote: ServiceRemoteWordPressComREST { + /// Used to format dates so the time information is omitted. + private static var dateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.locale = .init(identifier: "en_US_POSIX") + formatter.dateFormat = "yyyy-MM-dd" + + return formatter + }() + + public enum RequestError: Error { + case encodingFailure + } + + /// Fetches a number of blogging prompts for the specified site. + /// Note that this method hits wpcom/v2, which means the `WordPressComRestAPI` needs to be initialized with `LocaleKeyV2`. + /// + /// - Parameters: + /// - siteID: Used to check which prompts have been answered for the site with given `siteID`. + /// - number: The number of prompts to query. When not specified, this will default to remote implementation. + /// - fromDate: When specified, this will fetch prompts from the given date. When not specified, this will default to remote implementation. + /// - completion: A closure that will be called when the fetch request completes. + open func fetchPrompts(for siteID: NSNumber, + number: Int? = nil, + fromDate: Date? = nil, + completion: @escaping (Result<[RemoteBloggingPrompt], Error>) -> Void) { + let path = path(forEndpoint: "sites/\(siteID)/blogging-prompts", withVersion: ._2_0) + let requestParameter: [String: AnyHashable] = { + var params = [String: AnyHashable]() + + if let number, number > 0 { + params["number"] = number + } + + if let fromDate { + // convert to yyyy-MM-dd format, excluding the timezone information. + // the date parameter doesn't need to be timezone-accurate since prompts are grouped by date. + params["from"] = Self.dateFormatter.string(from: fromDate) + } + + return params + }() + + let decoder = JSONDecoder.apiDecoder + // our API decoder assumes that we're converting from snake case. + // revert it to default so the CodingKeys match the actual response keys. + decoder.keyDecodingStrategy = .useDefaultKeys + + Task { @MainActor in + await self.wordPressComRestApi + .perform( + .get, + URLString: path, + parameters: requestParameter as [String: AnyObject], + jsonDecoder: decoder, + type: [String: [RemoteBloggingPrompt]].self + ) + .map { $0.body.values.first ?? [] } + .mapError { error -> Error in error.asNSError() } + .execute(completion) + } + } + + /// Fetches the blogging prompts settings for a given site. + /// + /// - Parameters: + /// - siteID: The site ID for the blogging prompts settings. + /// - completion: Closure that will be called when the request completes. + open func fetchSettings(for siteID: NSNumber, completion: @escaping (Result) -> Void) { + let path = path(forEndpoint: "sites/\(siteID)/blogging-prompts/settings", withVersion: ._2_0) + Task { @MainActor in + await self.wordPressComRestApi.perform(.get, URLString: path, type: RemoteBloggingPromptsSettings.self) + .map { $0.body } + .mapError { error -> Error in error.asNSError() } + .execute(completion) + } + } + + /// Updates the blogging prompts settings to remote. + /// + /// This will return an updated settings object if at least one of the fields is successfully modified. + /// If nothing has changed, it will still be regarded as a successful operation; but nil will be returned. + /// + /// - Parameters: + /// - siteID: The site ID of the blogging prompts settings. + /// - settings: The updated settings to upload. + /// - completion: Closure that will be called when the request completes. + open func updateSettings(for siteID: NSNumber, + with settings: RemoteBloggingPromptsSettings, + completion: @escaping (Result) -> Void) { + let path = path(forEndpoint: "sites/\(siteID)/blogging-prompts/settings", withVersion: ._2_0) + var parameters = [String: AnyObject]() + do { + let data = try JSONEncoder().encode(settings) + parameters = try JSONSerialization.jsonObject(with: data) as? [String: AnyObject] ?? [:] + } catch { + completion(.failure(error)) + return + } + + // The parameter shouldn't be empty at this point. + // If by some chance it is, let's abort and return early. There could be something wrong with the parsing process. + guard !parameters.isEmpty else { + WPKitLogError("Error encoding RemoteBloggingPromptsSettings object: \(settings)") + completion(.failure(RequestError.encodingFailure)) + return + } + + wordPressComRESTAPI.post(path, parameters: parameters) { responseObject, _ in + do { + let data = try JSONSerialization.data(withJSONObject: responseObject) + let response = try JSONDecoder().decode(UpdateBloggingPromptsSettingsResponse.self, from: data) + completion(.success(response.updated)) + } catch { + completion(.failure(error)) + } + } failure: { error, _ in + completion(.failure(error)) + } + } +} + +// MARK: - Private helpers + +private extension BloggingPromptsServiceRemote { + /// An intermediate object representing the response structure after updating the prompts settings. + /// + /// If there is at least one updated field, the remote will return the full `RemoteBloggingPromptsSettings` object in the `updated` key. + /// Otherwise, if no fields are changed, the remote will assign an empty array to the `updated` key. + struct UpdateBloggingPromptsSettingsResponse: Decodable { + let updated: RemoteBloggingPromptsSettings? + + private enum CodingKeys: String, CodingKey { + case updated + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + // return nil when no fields are changed. + if let _ = try? container.decode(Array.self, forKey: .updated) { + self.updated = nil + return + } + + self.updated = try container.decode(RemoteBloggingPromptsSettings.self, forKey: .updated) + } + } +} diff --git a/Modules/Sources/WordPressKit/ChecksumUtil.swift b/Modules/Sources/WordPressKit/ChecksumUtil.swift new file mode 100644 index 000000000000..e9c3035b1e9e --- /dev/null +++ b/Modules/Sources/WordPressKit/ChecksumUtil.swift @@ -0,0 +1,18 @@ +import Foundation + +public class ChecksumUtil { + + /// Generates a checksum based on the encoded keys. + static func checksum(from codable: T) -> String where T: Encodable { + let encoder = JSONEncoder() + encoder.outputFormatting = .sortedKeys + let result: String + do { + let data = try encoder.encode(codable) + result = String(data: data, encoding: .utf8) ?? "" + } catch { + result = "" + } + return result.md5() + } +} diff --git a/Modules/Sources/WordPressKit/CommentServiceRemoteREST+ApiV2.swift b/Modules/Sources/WordPressKit/CommentServiceRemoteREST+ApiV2.swift new file mode 100644 index 000000000000..38a777252d6f --- /dev/null +++ b/Modules/Sources/WordPressKit/CommentServiceRemoteREST+ApiV2.swift @@ -0,0 +1,66 @@ +import Foundation +public extension CommentServiceRemoteREST { + /// Lists the available keys for the request parameter. + enum RequestKeys: String { + /// The parent comment's ID. In API v2, supplying this parameter filters the list to only contain + /// the child/reply comments of the specified ID. + case parent + + /// The dotcom user ID of the comment author. In API v2, supplying this parameter filters the list + /// to only contain comments authored by the specified ID. + case author + + /// Valid values are `view`, `edit`, or `embed`. When not specified, the default context is `view`. + case context + } + + /// Retrieves a list of comments in a site with the specified siteID. + /// - Parameters: + /// - siteID: The ID of the site that contains the specified comment. + /// - parameters: Additional request parameters. Optional. + /// - success: A closure that will be called when the request succeeds. + /// - failure: A closure that will be called when the request fails. + func getCommentsV2(for siteID: Int, + parameters: [RequestKeys: AnyHashable]? = nil, + success: @escaping ([RemoteCommentV2]) -> Void, + failure: @escaping (Error) -> Void) { + let path = coreV2Path(for: "sites/\(siteID)/comments") + let requestParameters: [String: AnyHashable] = { + guard let someParameters = parameters else { + return [:] + } + + return someParameters.reduce([String: AnyHashable]()) { result, pair in + var result = result + result[pair.key.rawValue] = pair.value + return result + } + }() + + Task { @MainActor in + await self.wordPressComRestApi + .perform( + .get, + URLString: path, + parameters: requestParameters as [String: AnyObject], + type: [RemoteCommentV2].self + ) + .map { $0.body } + .mapError { error -> Error in error.asNSError() } + .execute(onSuccess: success, onFailure: failure) + } + } + +} + +// MARK: - Private Helpers + +private extension CommentServiceRemoteREST { + struct Constants { + static let coreV2String = "wp/v2" + } + + func coreV2Path(for endpoint: String) -> String { + return "\(Constants.coreV2String)/\(endpoint)" + } +} diff --git a/Modules/Sources/WordPressKit/DashboardServiceRemote.swift b/Modules/Sources/WordPressKit/DashboardServiceRemote.swift new file mode 100644 index 000000000000..53182204d86b --- /dev/null +++ b/Modules/Sources/WordPressKit/DashboardServiceRemote.swift @@ -0,0 +1,49 @@ +import Foundation +import WordPressKitObjC + +open class DashboardServiceRemote: ServiceRemoteWordPressComREST { + open func fetch( + cards: [String], + forBlogID blogID: Int, + deviceId: String, + success: @escaping (NSDictionary) -> Void, + failure: @escaping (Error) -> Void + ) { + let requestUrl = self.path(forEndpoint: "sites/\(blogID)/dashboard/cards-data/", withVersion: ._2_0) + var params: [String: AnyObject]? + + do { + params = try self.makeQueryParams(cards: cards, deviceId: deviceId) + } catch { + failure(error) + } + + wordPressComRESTAPI.get(requestUrl, + parameters: params, + success: { response, _ in + guard let cards = response as? NSDictionary else { + failure(ResponseError.decodingFailure) + return + } + + success(cards) + }, failure: { error, _ in + failure(error) + WPKitLogError("Error fetching dashboard cards: \(error)") + }) + } + + private func makeQueryParams(cards: [String], deviceId: String) throws -> [String: AnyObject] { + let cardsParams: [String: AnyObject] = [ + "cards": cards.joined(separator: ",") as NSString + ] + let featureFlagParams: [String: AnyObject]? = try SessionDetails(deviceId: deviceId).dictionaryRepresentation() + return cardsParams.merging(featureFlagParams ?? [:]) { first, second in + return first + } + } + + enum ResponseError: Error { + case decodingFailure + } +} diff --git a/Modules/Sources/WordPressKit/Date+endOfDay.swift b/Modules/Sources/WordPressKit/Date+endOfDay.swift new file mode 100644 index 000000000000..7ce569f7881e --- /dev/null +++ b/Modules/Sources/WordPressKit/Date+endOfDay.swift @@ -0,0 +1,9 @@ +import Foundation + +extension Date { + /// Returns a Date representing the last second of the given day + /// + func endOfDay() -> Date? { + Calendar.current.date(byAdding: .second, value: 86399, to: self) + } +} diff --git a/Modules/Sources/WordPressKit/Decodable+Dictionary.swift b/Modules/Sources/WordPressKit/Decodable+Dictionary.swift new file mode 100644 index 000000000000..4a5b1ee6c56e --- /dev/null +++ b/Modules/Sources/WordPressKit/Decodable+Dictionary.swift @@ -0,0 +1,100 @@ +import Foundation +struct JSONCodingKeys: CodingKey { + var stringValue: String + + init?(stringValue: String) { + self.stringValue = stringValue + } + + var intValue: Int? + + init?(intValue: Int) { + self.init(stringValue: "\(intValue)") + self.intValue = intValue + } +} + +/// Add support to decode to a Dictionary +/// From: https://stackoverflow.com/q/44603248 +extension KeyedDecodingContainer { + func decode(_ type: Dictionary.Type, forKey key: K) throws -> [String: Any] { + let container = try self.nestedContainer(keyedBy: JSONCodingKeys.self, forKey: key) + return try container.decode(type) + } + + func decodeIfPresent(_ type: Dictionary.Type, forKey key: K) throws -> [String: Any]? { + guard contains(key) else { + return nil + } + guard try decodeNil(forKey: key) == false else { + return nil + } + return try decode(type, forKey: key) + } + + func decode(_ type: Array.Type, forKey key: K) throws -> [Any] { + var container = try self.nestedUnkeyedContainer(forKey: key) + return try container.decode(type) + } + + func decodeIfPresent(_ type: Array.Type, forKey key: K) throws -> [Any]? { + guard contains(key) else { + return nil + } + guard try decodeNil(forKey: key) == false else { + return nil + } + return try decode(type, forKey: key) + } + + func decode(_ type: Dictionary.Type) throws -> [String: Any] { + var dictionary = [String: Any]() + + for key in allKeys { + if let boolValue = try? decode(Bool.self, forKey: key) { + dictionary[key.stringValue] = boolValue + } else if let stringValue = try? decode(String.self, forKey: key) { + dictionary[key.stringValue] = stringValue + } else if let intValue = try? decode(Int.self, forKey: key) { + dictionary[key.stringValue] = intValue + } else if let doubleValue = try? decode(Double.self, forKey: key) { + dictionary[key.stringValue] = doubleValue + } else if let nestedDictionary = try? decode(Dictionary.self, forKey: key) { + dictionary[key.stringValue] = nestedDictionary + } else if let nestedArray = try? decode(Array.self, forKey: key) { + dictionary[key.stringValue] = nestedArray + } + } + return dictionary + } +} + +extension UnkeyedDecodingContainer { + + mutating func decode(_ type: Array.Type) throws -> [Any] { + var array: [Any] = [] + while isAtEnd == false { + // See if the current value in the JSON array is `null` first and prevent infite recursion with nested arrays. + if try decodeNil() { + continue + } else if let value = try? decode(Bool.self) { + array.append(value) + } else if let value = try? decode(Double.self) { + array.append(value) + } else if let value = try? decode(String.self) { + array.append(value) + } else if let nestedDictionary = try? decode(Dictionary.self) { + array.append(nestedDictionary) + } else if var nestedContainer = try? nestedUnkeyedContainer(), let nestedArray = try? nestedContainer.decode(Array.self) { + array.append(nestedArray) + } + } + return array + } + + mutating func decode(_ type: Dictionary.Type) throws -> [String: Any] { + + let nestedContainer = try self.nestedContainer(keyedBy: JSONCodingKeys.self) + return try nestedContainer.decode(type) + } +} diff --git a/Modules/Sources/WordPressKit/Dictionary+Helpers.swift b/Modules/Sources/WordPressKit/Dictionary+Helpers.swift new file mode 100644 index 000000000000..1bf8bb0e9748 --- /dev/null +++ b/Modules/Sources/WordPressKit/Dictionary+Helpers.swift @@ -0,0 +1,26 @@ +import Foundation + +// MARK: - Dictionary Helper Methods +// +extension Dictionary { + /// This method attempts to convert a given value into a String, if it's not already the + /// case. Initial implementation supports only NSNumber. This is meant for bulletproof parsing, + /// in which a String value might be serialized, backend side, as a Number. + /// + /// - Parameter key: The key to retrieve. + /// + /// - Returns: Value as a String (when possible!) + /// + func valueAsString(forKey key: Key) -> String? { + guard let value = self[key] else { + return nil + } + if let string = value as? String { + return string + } else if let number = value as? NSNumber { + return number.description + } else { + return nil + } + } +} diff --git a/Modules/Sources/WordPressKit/DomainContactInformation.swift b/Modules/Sources/WordPressKit/DomainContactInformation.swift new file mode 100644 index 000000000000..b9895c428d0f --- /dev/null +++ b/Modules/Sources/WordPressKit/DomainContactInformation.swift @@ -0,0 +1,59 @@ +import Foundation + +public struct ValidateDomainContactInformationResponse: Codable { + public struct Messages: Codable { + public var phone: [String]? + public var email: [String]? + public var postalCode: [String]? + public var countryCode: [String]? + public var city: [String]? + public var address1: [String]? + public var address2: [String]? + public var firstName: [String]? + public var lastName: [String]? + public var state: [String]? + public var organization: [String]? + } + + public var success: Bool = false + public var messages: Messages? + + /// Returns true if any of the properties within `messages` has a value. + /// + public var hasMessages: Bool { + if let messages { + let mirror = Mirror(reflecting: messages) + + for child in mirror.children { + let childMirror = Mirror(reflecting: child.value) + + if childMirror.displayStyle == .optional, + let _ = childMirror.children.first { + return true + } + } + } + + return false + } + + public init() { + } +} + +public struct DomainContactInformation: Codable { + public var phone: String? + public var email: String? + public var postalCode: String? + public var countryCode: String? + public var city: String? + public var address1: String? + public var firstName: String? + public var lastName: String? + public var fax: String? + public var state: String? + public var organization: String? + + public init() { + } +} diff --git a/Modules/Sources/WordPressKit/DomainsServiceRemote+AllDomains.swift b/Modules/Sources/WordPressKit/DomainsServiceRemote+AllDomains.swift new file mode 100644 index 000000000000..f275784f254a --- /dev/null +++ b/Modules/Sources/WordPressKit/DomainsServiceRemote+AllDomains.swift @@ -0,0 +1,181 @@ +import Foundation + +extension DomainsServiceRemote { + + // MARK: - API + + /// Makes a call request to `GET /v1.1/all-domains` and returns a list of domain objects. + /// + /// The endpoint accepts 3 **optionals** query params: + /// - `resolve_status` of type `boolean`. If `true`, the response will include a `status` attribute for each `domain` object. + /// - `no_wpcom`of type `boolean`. If `true`, the respnse won't include `wpcom` domains. + /// - `locale` of type `string`. Used for string localization. + public func fetchAllDomains(params: AllDomainsEndpointParams? = nil, completion: @escaping (AllDomainsEndpointResult) -> Void) { + let path = self.path(forEndpoint: "all-domains", withVersion: ._1_1) + let parameters: [String: AnyObject]? + + do { + parameters = try queryParameters(from: params) + } catch let error { + completion(.failure(error)) + return + } + + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + Task { @MainActor in + await self.wordPressComRestApi + .perform( + .get, + URLString: path, + parameters: parameters, + jsonDecoder: decoder, + type: AllDomainsEndpointResponse.self + ) + .map { $0.body.domains } + .mapError { error -> Error in error.asNSError() } + .execute(completion) + } + } + + private func queryParameters(from params: AllDomainsEndpointParams?) throws -> [String: AnyObject]? { + guard let params else { + return nil + } + let encoder = JSONEncoder() + let data = try encoder.encode(params) + let dict = try JSONSerialization.jsonObject(with: data) as? [String: AnyObject] + return dict + } + + // MARK: - Public Types + + public typealias AllDomainsEndpointResult = Result<[AllDomainsListItem], Error> + + public struct AllDomainsEndpointParams { + + public var resolveStatus: Bool = false + public var noWPCOM: Bool = false + public var locale: String? + + public init() {} + } + + public struct AllDomainsListItem { + + public enum StatusType: String { + case success + case premium + case neutral + case warning + case alert + case error + } + + public struct Status { + + public let value: String + public let type: StatusType + + public init(value: String, type: StatusType) { + self.value = value + self.type = type + } + } + + public let domain: String + public let blogId: Int + public let blogName: String + public let type: DomainType + public let isDomainOnlySite: Bool + public let isWpcomStagingDomain: Bool + public let hasRegistration: Bool + public let registrationDate: Date? + public let expiryDate: Date? + public let wpcomDomain: Bool + public let currentUserIsOwner: Bool? + public let siteSlug: String + public let status: Status? + } + + // MARK: - Private Types + + private struct AllDomainsEndpointResponse: Decodable { + let domains: [AllDomainsListItem] + } +} + +// MARK: - Encoding / Decoding + +extension DomainsServiceRemote.AllDomainsEndpointParams: Encodable { + + enum CodingKeys: String, CodingKey { + case resolveStatus = "resolve_status" + case locale + case noWPCOM = "no_wpcom" + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode("\(resolveStatus)", forKey: .resolveStatus) + try container.encode("\(noWPCOM)", forKey: .noWPCOM) + try container.encodeIfPresent(locale, forKey: .locale) + } +} + +extension DomainsServiceRemote.AllDomainsListItem.StatusType: Decodable { +} + +extension DomainsServiceRemote.AllDomainsListItem.Status: Decodable { + enum CodingKeys: String, CodingKey { + case value = "status" + case type = "status_type" + } +} + +extension DomainsServiceRemote.AllDomainsListItem: Decodable { + + enum CodingKeys: String, CodingKey { + case domain + case blogId = "blog_id" + case blogName = "blog_name" + case type + case isDomainOnlySite = "is_domain_only_site" + case isWpcomStagingDomain = "is_wpcom_staging_domain" + case hasRegistration = "has_registration" + case registrationDate = "registration_date" + case expiryDate = "expiry" + case wpcomDomain = "wpcom_domain" + case currentUserIsOwner = "current_user_is_owner" + case siteSlug = "site_slug" + case status = "domain_status" + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.domain = try container.decode(String.self, forKey: .domain) + self.blogId = try container.decode(Int.self, forKey: .blogId) + self.blogName = try container.decode(String.self, forKey: .blogName) + self.isDomainOnlySite = try container.decode(Bool.self, forKey: .isDomainOnlySite) + self.isWpcomStagingDomain = try container.decode(Bool.self, forKey: .isWpcomStagingDomain) + self.hasRegistration = try container.decode(Bool.self, forKey: .hasRegistration) + self.wpcomDomain = try container.decode(Bool.self, forKey: .wpcomDomain) + self.currentUserIsOwner = try container.decode(Bool?.self, forKey: .currentUserIsOwner) + self.siteSlug = try container.decode(String.self, forKey: .siteSlug) + self.registrationDate = try { + if let timestamp = try? container.decodeIfPresent(String.self, forKey: .registrationDate), !timestamp.isEmpty { + return try container.decode(Date.self, forKey: .registrationDate) + } + return nil + }() + self.expiryDate = try { + if let timestamp = try? container.decodeIfPresent(String.self, forKey: .expiryDate), !timestamp.isEmpty { + return try container.decode(Date.self, forKey: .expiryDate) + } + return nil + }() + let type: String = try container.decode(String.self, forKey: .type) + self.type = .init(type: type, wpComDomain: wpcomDomain, hasRegistration: hasRegistration) + self.status = try container.decodeIfPresent(Status.self, forKey: .status) + } +} diff --git a/Modules/Sources/WordPressKit/DomainsServiceRemote.swift b/Modules/Sources/WordPressKit/DomainsServiceRemote.swift new file mode 100644 index 000000000000..154b6f692256 --- /dev/null +++ b/Modules/Sources/WordPressKit/DomainsServiceRemote.swift @@ -0,0 +1,322 @@ +import Foundation +import WordPressKitObjC + +/// Allows the construction of a request for domain suggestions. +/// +public struct DomainSuggestionRequest { + public typealias DomainSuggestionType = DomainsServiceRemote.DomainSuggestionType + + public let query: String + public let segmentID: Int64? + public let quantity: Int? + public let suggestionType: DomainSuggestionType? + + public init(query: String, segmentID: Int64? = nil, quantity: Int? = nil, suggestionType: DomainSuggestionType? = nil) { + self.query = query + self.segmentID = segmentID + self.quantity = quantity + self.suggestionType = suggestionType + } +} + +public struct DomainSuggestion: Codable { + public let domainName: String + public let productID: Int? + public let supportsPrivacy: Bool? + public let costString: String + public let cost: Double? + public let saleCost: Double? + public let isFree: Bool + public let currencyCode: String? + + public var domainNameStrippingSubdomain: String { + return domainName.components(separatedBy: ".").first ?? domainName + } + + public init( + domainName: String, + productID: Int?, + supportsPrivacy: Bool?, + costString: String, + cost: Double? = nil, + saleCost: Double? = nil, + isFree: Bool = false, + currencyCode: String? = nil + ) { + self.domainName = domainName + self.productID = productID + self.supportsPrivacy = supportsPrivacy + self.costString = costString + self.cost = cost + self.saleCost = saleCost + self.isFree = isFree + self.currencyCode = currencyCode + } + + public init(json: [String: AnyObject]) throws { + guard let domain = json["domain_name"] as? String else { + throw DomainsServiceRemote.ResponseError.decodingFailed + } + + self.domainName = domain + self.productID = json["product_id"] as? Int ?? nil + self.supportsPrivacy = json["supports_privacy"] as? Bool ?? nil + self.costString = json["cost"] as? String ?? "" + self.cost = json["raw_price"] as? Double + self.saleCost = json["sale_cost"] as? Double + self.isFree = json["is_free"] as? Bool ?? false + self.currencyCode = json["currency_code"] as? String + } +} + +public class DomainsServiceRemote: ServiceRemoteWordPressComREST { + public enum ResponseError: Error { + case decodingFailed + } + + public enum DomainSuggestionType { + case noWordpressDotCom + case includeWordPressDotCom + case onlyWordPressDotCom + case wordPressDotComAndDotBlogSubdomains + + /// Includes free dotcom sudomains and paid domains. + case freeAndPaid + + case allowlistedTopLevelDomains([String]) + + fileprivate func parameters() -> [String: AnyObject] { + switch self { + case .noWordpressDotCom: + return ["include_wordpressdotcom": false as AnyObject] + case .includeWordPressDotCom: + return ["include_wordpressdotcom": true as AnyObject, + "only_wordpressdotcom": false as AnyObject] + case .onlyWordPressDotCom: + return ["only_wordpressdotcom": true as AnyObject] + case .wordPressDotComAndDotBlogSubdomains: + return ["include_dotblogsubdomain": true as AnyObject, + "vendor": "dot" as AnyObject, + "only_wordpressdotcom": true as AnyObject, + "include_wordpressdotcom": true as AnyObject] + case .freeAndPaid: + return ["include_dotblogsubdomain": false as AnyObject, + "include_wordpressdotcom": true as AnyObject, + "vendor": "mobile" as AnyObject] + case .allowlistedTopLevelDomains(let allowlistedTLDs): + return ["tlds": allowlistedTLDs.joined(separator: ",") as AnyObject] + } + } + } + + public func getDomainsForSite(_ siteID: Int, success: @escaping ([RemoteDomain]) -> Void, failure: @escaping (Error) -> Void) { + let endpoint = "sites/\(siteID)/domains" + let path = self.path(forEndpoint: endpoint, withVersion: ._1_1) + + wordPressComRESTAPI.get(path, parameters: nil, + success: { + response, _ in + do { + try success(mapDomainsResponse(response)) + } catch { + WPKitLogError("Error parsing domains response (\(error)): \(response)") + failure(error) + } + }, failure: { + error, _ in + failure(error) + }) + } + + public func setPrimaryDomainForSite(siteID: Int, domain: String, success: @escaping () -> Void, failure: @escaping (Error) -> Void) { + let endpoint = "sites/\(siteID)/domains/primary" + let path = self.path(forEndpoint: endpoint, withVersion: ._1_1) + + let parameters: [String: AnyObject] = ["domain": domain as AnyObject] + + wordPressComRESTAPI.post(path, parameters: parameters, + success: { _, _ in + + success() + }, failure: { error, _ in + + failure(error) + }) + } + + @objc public func getStates(for countryCode: String, + success: @escaping ([WPState]) -> Void, + failure: @escaping (Error) -> Void) { + let endPoint = "domains/supported-states/\(countryCode)" + let servicePath = path(forEndpoint: endPoint, withVersion: ._1_1) + + wordPressComRESTAPI.get( + servicePath, + parameters: nil, + success: { + response, _ in + do { + guard let json = response as? [AnyObject] else { + throw ResponseError.decodingFailed + } + let data = try JSONSerialization.data(withJSONObject: json, options: .prettyPrinted) + let decodedResult = try JSONDecoder.apiDecoder.decode([WPState].self, from: data) + success(decodedResult) + } catch { + WPKitLogError("Error parsing State list for country code (\(error)): \(response)") + failure(error) + } + }, failure: { error, _ in + failure(error) + }) + } + + public func getDomainContactInformation(success: @escaping (DomainContactInformation) -> Void, + failure: @escaping (Error) -> Void) { + let endPoint = "me/domain-contact-information" + let servicePath = path(forEndpoint: endPoint, withVersion: ._1_1) + + wordPressComRESTAPI.get( + servicePath, + parameters: nil, + success: { (response, _) in + do { + let data = try JSONSerialization.data(withJSONObject: response, options: .prettyPrinted) + let decodedResult = try JSONDecoder.apiDecoder.decode(DomainContactInformation.self, from: data) + success(decodedResult) + } catch { + WPKitLogError("Error parsing DomainContactInformation (\(error)): \(response)") + failure(error) + } + }) { (error, _) in + failure(error) + } + } + + public func validateDomainContactInformation(contactInformation: [String: String], + domainNames: [String], + success: @escaping (ValidateDomainContactInformationResponse) -> Void, + failure: @escaping (Error) -> Void) { + let endPoint = "me/domain-contact-information/validate" + let servicePath = path(forEndpoint: endPoint, withVersion: ._1_1) + + let parameters: [String: AnyObject] = ["contact_information": contactInformation as AnyObject, + "domain_names": domainNames as AnyObject] + wordPressComRESTAPI.post( + servicePath, + parameters: parameters, + success: { response, _ in + do { + let data = try JSONSerialization.data(withJSONObject: response, options: .prettyPrinted) + let decodedResult = try JSONDecoder.apiDecoder.decode(ValidateDomainContactInformationResponse.self, from: data) + success(decodedResult) + } catch { + WPKitLogError("Error parsing ValidateDomainContactInformationResponse (\(error)): \(response)") + failure(error) + } + }) { (error, _) in + failure(error) + } + } + + public func getDomainSuggestions(request: DomainSuggestionRequest, + success: @escaping ([DomainSuggestion]) -> Void, + failure: @escaping (Error) -> Void) { + let endPoint = "domains/suggestions" + let servicePath = path(forEndpoint: endPoint, withVersion: ._1_1) + var parameters: [String: AnyObject] = [ + "query": request.query as AnyObject + ] + + if let suggestionType = request.suggestionType { + parameters.merge(suggestionType.parameters(), uniquingKeysWith: { $1 }) + } + + if let segmentID = request.segmentID { + parameters["segment_id"] = segmentID as AnyObject + } + + if let quantity = request.quantity { + parameters["quantity"] = quantity as AnyObject + } + + wordPressComRESTAPI.get(servicePath, + parameters: parameters, + success: { + response, _ in + do { + let suggestions = try map(suggestions: response) + success(suggestions) + } catch { + WPKitLogError("Error parsing domains response (\(error)): \(response)") + failure(error) + } + }, failure: { + error, _ in + failure(error) + }) + } +} + +private func map(suggestions response: Any) throws -> [DomainSuggestion] { + guard let jsonSuggestions = response as? [[String: AnyObject]] else { + throw DomainsServiceRemote.ResponseError.decodingFailed + } + + var suggestions: [DomainSuggestion] = [] + for jsonSuggestion in jsonSuggestions { + do { + let suggestion = try DomainSuggestion(json: jsonSuggestion) + suggestions.append(suggestion) + } + } + return suggestions +} + +private func mapDomainsResponse(_ response: Any) throws -> [RemoteDomain] { + guard let json = response as? [String: AnyObject], + let domainsJson = json["domains"] as? [[String: AnyObject]] else { + throw DomainsServiceRemote.ResponseError.decodingFailed + } + + let domains = try domainsJson.map { domainJson -> RemoteDomain in + + guard let domainName = domainJson["domain"] as? String, + let isPrimary = domainJson["primary_domain"] as? Bool else { + throw DomainsServiceRemote.ResponseError.decodingFailed + } + + let autoRenewing = domainJson["auto_renewing"] as? Bool + let autoRenewalDate = domainJson["auto_renewal_date"] as? String + let expirySoon = domainJson["expiry_soon"] as? Bool + let expired = domainJson["expired"] as? Bool + let expiryDate = domainJson["expiry"] as? String + + return RemoteDomain(domainName: domainName, + isPrimaryDomain: isPrimary, + domainType: domainTypeFromDomainJSON(domainJson), + autoRenewing: autoRenewing, + autoRenewalDate: autoRenewalDate, + expirySoon: expirySoon, + expired: expired, + expiryDate: expiryDate) + } + + return domains +} + +private func domainTypeFromDomainJSON(_ domainJson: [String: AnyObject]) -> DomainType { + if let type = domainJson["type"] as? String, type == "redirect" { + return .siteRedirect + } + + if let wpComDomain = domainJson["wpcom_domain"] as? Bool, wpComDomain == true { + return .wpCom + } + + if let hasRegistration = domainJson["has_registration"] as? Bool, hasRegistration == true { + return .registered + } + + return .mapped +} diff --git a/Modules/Sources/WordPressKit/EditorServiceRemote.swift b/Modules/Sources/WordPressKit/EditorServiceRemote.swift new file mode 100644 index 000000000000..029679575255 --- /dev/null +++ b/Modules/Sources/WordPressKit/EditorServiceRemote.swift @@ -0,0 +1,65 @@ +import Foundation +import WordPressKitObjC + +public class EditorServiceRemote: ServiceRemoteWordPressComREST { + public func postDesignateMobileEditor(_ siteID: Int, editor: EditorSettings.Mobile, success: @escaping (EditorSettings) -> Void, failure: @escaping (Error) -> Void) { + let endpoint = "sites/\(siteID)/gutenberg?platform=mobile&editor=\(editor.rawValue)" + let path = self.path(forEndpoint: endpoint, withVersion: ._2_0) + + wordPressComRESTAPI.post(path, parameters: nil, success: { (responseObject, _) in + do { + let settings = try EditorSettings(with: responseObject) + success(settings) + } catch { + failure(error) + } + }) { (error, _) in + failure(error) + } + } + + public func postDesignateMobileEditorForAllSites(_ editor: EditorSettings.Mobile, setOnlyIfEmpty: Bool = true, success: @escaping ([Int: EditorSettings.Mobile]) -> Void, failure: @escaping (Error) -> Void) { + let endpoint = "me/gutenberg" + let path = self.path(forEndpoint: endpoint, withVersion: ._2_0) + + let parameters = [ + "platform": "mobile", + "editor": editor.rawValue, + "set_only_if_empty": setOnlyIfEmpty + ] as [String: AnyObject] + + wordPressComRESTAPI.post(path, parameters: parameters, success: { (responseObject, _) in + guard let response = responseObject as? [String: String] else { + if let boolResponse = responseObject as? Bool, boolResponse == false { + return failure(EditorSettings.Error.badRequest) + } + return failure(EditorSettings.Error.badResponse) + } + + let mappedResponse = response.reduce(into: [Int: EditorSettings.Mobile](), { (result, response) in + if let id = Int(response.key), let editor = EditorSettings.Mobile(rawValue: response.value) { + result[id] = editor + } + }) + success(mappedResponse) + }) { (error, _) in + failure(error) + } + } + + public func getEditorSettings(_ siteID: Int, success: @escaping (EditorSettings) -> Void, failure: @escaping (Error) -> Void) { + let endpoint = "sites/\(siteID)/gutenberg" + let path = self.path(forEndpoint: endpoint, withVersion: ._2_0) + + wordPressComRESTAPI.get(path, parameters: nil, success: { (responseObject, _) in + do { + let settings = try EditorSettings(with: responseObject) + success(settings) + } catch { + failure(error) + } + }) { (error, _) in + failure(error) + } + } +} diff --git a/Modules/Sources/WordPressKit/EditorSettings.swift b/Modules/Sources/WordPressKit/EditorSettings.swift new file mode 100644 index 000000000000..d5b39b5d9c49 --- /dev/null +++ b/Modules/Sources/WordPressKit/EditorSettings.swift @@ -0,0 +1,59 @@ +import Foundation +private struct RemoteEditorSettings: Codable { + let editorMobile: String + let editorWeb: String +} + +public struct EditorSettings { + public enum Error: Swift.Error { + case decodingFailed + case unknownEditor(String) + case badRequest + case badResponse + } + + /// Editor choosen by the user to be used on Mobile + /// + /// - gutenberg: The block editor + /// - aztec: The mobile "classic" editor + /// - notSet: The user has never saved they preference on remote + public enum Mobile: String { + case gutenberg + case aztec + case notSet = "" + } + + /// Editor choosen by the user to be used on Web + /// + /// - classic: The classic editor + /// - gutenberg: The block editor + public enum Web: String { + case classic + case gutenberg + } + + public let mobile: Mobile + public let web: Web +} + +extension EditorSettings { + init(with response: Any) throws { + guard let response = response as? [String: AnyObject] else { + throw NSError(domain: NSURLErrorDomain, code: NSURLErrorBadServerResponse, userInfo: nil) + } + + let data = try JSONSerialization.data(withJSONObject: response, options: .prettyPrinted) + let editorPreferenesRemote = try JSONDecoder.apiDecoder.decode(RemoteEditorSettings.self, from: data) + try self.init(with: editorPreferenesRemote) + } + + private init(with remote: RemoteEditorSettings) throws { + guard + let mobile = Mobile(rawValue: remote.editorMobile), + let web = Web(rawValue: remote.editorWeb) + else { + throw Error.decodingFailed + } + self = EditorSettings(mobile: mobile, web: web) + } +} diff --git a/Modules/Sources/WordPressKit/Either.swift b/Modules/Sources/WordPressKit/Either.swift new file mode 100644 index 000000000000..4b895f7a8b7c --- /dev/null +++ b/Modules/Sources/WordPressKit/Either.swift @@ -0,0 +1,15 @@ +import Foundation + +enum Either { + case left(L) + case right(R) + + func map(left: (L) -> T, right: (R) -> T) -> T { + switch self { + case let .left(value): + return left(value) + case let .right(value): + return right(value) + } + } +} diff --git a/Modules/Sources/WordPressKit/Enum+UnknownCaseRepresentable.swift b/Modules/Sources/WordPressKit/Enum+UnknownCaseRepresentable.swift new file mode 100644 index 000000000000..9cc5abb63478 --- /dev/null +++ b/Modules/Sources/WordPressKit/Enum+UnknownCaseRepresentable.swift @@ -0,0 +1,13 @@ +import Foundation +/// Allows automatic defaulting to `unknown` for any Enum that conforms to `UnknownCaseRepresentable` +/// Credits: https://www.latenightswift.com/2019/02/04/unknown-enum-cases/ +protocol UnknownCaseRepresentable: RawRepresentable, CaseIterable where RawValue: Equatable { + static var unknownCase: Self { get } +} + +extension UnknownCaseRepresentable { + public init(rawValue: RawValue) { + let value = Self.allCases.first(where: { $0.rawValue == rawValue }) + self = value ?? Self.unknownCase + } +} diff --git a/Modules/Sources/WordPressKit/Exports.swift b/Modules/Sources/WordPressKit/Exports.swift new file mode 100644 index 000000000000..96fc9d929098 --- /dev/null +++ b/Modules/Sources/WordPressKit/Exports.swift @@ -0,0 +1,15 @@ +@_exported import WordPressKitModels +@_exported import WordPressKitObjC +@_exported import WordPressKitObjCUtils + +extension ServiceRemoteWordPressComREST { + public var wordPressComRestApi: WordPressComRestApi { + self.wordPressComRESTAPI as! WordPressComRestApi + } +} + +extension ServiceRemoteWordPressXMLRPC { + public var xmlrpcApi: WordPressOrgXMLRPCApi { + self.api as! WordPressOrgXMLRPCApi + } +} diff --git a/Modules/Sources/WordPressKit/FeatureFlag.swift b/Modules/Sources/WordPressKit/FeatureFlag.swift new file mode 100644 index 000000000000..997285ebd316 --- /dev/null +++ b/Modules/Sources/WordPressKit/FeatureFlag.swift @@ -0,0 +1,51 @@ +import Foundation + +public struct FeatureFlag { + public let title: String + public let value: Bool + + public init(title: String, value: Bool) { + self.title = title + self.value = value + } +} + +// Codable Conformance is used to create mock objects in testing +extension FeatureFlag: Codable { + + struct DynamicKey: CodingKey { + var stringValue: String + init(stringValue: String) { + self.stringValue = stringValue + } + + var intValue: Int? + + init(intValue: Int) { + self.intValue = intValue + self.stringValue = "\(intValue)" + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: DynamicKey.self) + try container.encode(self.value, forKey: DynamicKey(stringValue: self.title)) + } +} + +/// Comparable Conformance is used to compare objects in testing, and to provide stable `FeatureFlagList` ordering +extension FeatureFlag: Comparable { + public static func < (lhs: FeatureFlag, rhs: FeatureFlag) -> Bool { + lhs.title < rhs.title + } +} + +public typealias FeatureFlagList = [FeatureFlag] + +extension FeatureFlagList { + public var dictionaryValue: [String: Bool] { + self.reduce(into: [:]) { + $0[$1.title] = $1.value + } + } +} diff --git a/Modules/Sources/WordPressKit/FeatureFlagRemote.swift b/Modules/Sources/WordPressKit/FeatureFlagRemote.swift new file mode 100644 index 000000000000..066e2b91243f --- /dev/null +++ b/Modules/Sources/WordPressKit/FeatureFlagRemote.swift @@ -0,0 +1,59 @@ +import Foundation +import UIKit +import WordPressKitObjC + +open class FeatureFlagRemote: ServiceRemoteWordPressComREST { + + public typealias FeatureFlagResponseCallback = (Result) -> Void + + public enum FeatureFlagRemoteError: Error { + case InvalidDataError + } + + open func getRemoteFeatureFlags(forDeviceId deviceId: String, callback: @escaping FeatureFlagResponseCallback) { + let params = SessionDetails(deviceId: deviceId) + let endpoint = "mobile/feature-flags" + let path = self.path(forEndpoint: endpoint, withVersion: ._2_0) + var dictionary: [String: AnyObject]? + + do { + dictionary = try params.dictionaryRepresentation() + } catch let error { + callback(.failure(error)) + return + } + + wordPressComRESTAPI.get(path, + parameters: dictionary, + success: { response, _ in + + if let featureFlagList = response as? NSDictionary { + + let reconstitutedList = featureFlagList.compactMap { row -> FeatureFlag? in + guard + let title = row.key as? String, + let value = row.value as? Bool + else { + return nil + } + + return FeatureFlag(title: title, value: value) + }.sorted() + + callback(.success(reconstitutedList)) + } else { + callback(.failure(FeatureFlagRemoteError.InvalidDataError)) + } + + }, failure: { error, response in + WPKitLogError("Error retrieving remote feature flags") + WPKitLogError("\(error)") + + if let response { + WPKitLogDebug("Response Code: \(response.statusCode)") + } + + callback(.failure(error)) + }) + } +} diff --git a/Modules/Sources/WordPressKit/GravatarServiceRemote.swift b/Modules/Sources/WordPressKit/GravatarServiceRemote.swift new file mode 100644 index 000000000000..59e7e7f36b65 --- /dev/null +++ b/Modules/Sources/WordPressKit/GravatarServiceRemote.swift @@ -0,0 +1,159 @@ +import Foundation + +/// This ServiceRemote encapsulates all of the interaction with the Gravatar endpoint. +/// +open class GravatarServiceRemote { + let baseGravatarURL = "https://www.gravatar.com/" + + public init() {} + + /// This method fetches the Gravatar profile for the specified email address. + /// + /// - Parameters: + /// - email: The email address of the gravatar profile to fetch. + /// - success: A success block. + /// - failure: A failure block. + /// + open func fetchProfile(_ email: String, success: @escaping ((_ profile: RemoteGravatarProfile) -> Void), failure: @escaping ((_ error: Error?) -> Void)) { + guard let hash = (email as NSString).md5() else { + assertionFailure() + return + } + + fetchProfile(hash: hash, success: success, failure: failure) + } + + /// This method fetches the Gravatar profile for the specified user hash value. + /// + /// - Parameters: + /// - hash: The hash value of the email address of the gravatar profile to fetch. + /// - success: A success block. + /// - failure: A failure block. + /// + open func fetchProfile(hash: String, success: @escaping ((_ profile: RemoteGravatarProfile) -> Void), failure: @escaping ((_ error: Error?) -> Void)) { + let path = baseGravatarURL + hash + ".json" + guard let targetURL = URL(string: path) else { + assertionFailure() + return + } + + let session = URLSession.shared + let task = session.dataTask(with: targetURL) { (data: Data?, _: URLResponse?, error: Error?) in + guard error == nil, let data else { + failure(error) + return + } + do { + let jsonData = try JSONSerialization.jsonObject(with: data, options: .allowFragments) + + guard let jsonDictionary = jsonData as? [String: [Any]], + let entry = jsonDictionary["entry"], + let profileData = entry.first as? NSDictionary else { + DispatchQueue.main.async { + // This case typically happens when the endpoint does + // successfully return but doesn't find the user. + failure(nil) + } + return + } + + let profile = RemoteGravatarProfile(dictionary: profileData) + DispatchQueue.main.async { + success(profile) + } + return + + } catch { + failure(error) + return + } + } + + task.resume() + } + + /// This method hits the Gravatar Endpoint, and uploads a new image, to be used as profile. + /// + /// - Parameters: + /// - image: The new Gravatar Image, to be uploaded + /// - completion: An optional closure to be executed on completion. + /// + open func uploadImage(_ image: UIImage, accountEmail: String, accountToken: String, completion: ((_ error: NSError?) -> Void)?) { + guard let targetURL = URL(string: UploadParameters.endpointURL) else { + assertionFailure() + return + } + + // Boundary + let boundary = boundaryForRequest() + + // Request + let request = NSMutableURLRequest(url: targetURL) + request.httpMethod = UploadParameters.HTTPMethod + request.setValue("Bearer \(accountToken)", forHTTPHeaderField: "Authorization") + request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") + + // Body + let gravatarData = image.pngData()! + let requestBody = bodyWithGravatarData(gravatarData, account: accountEmail, boundary: boundary) + + // Task + let session = URLSession.shared + let task = session.uploadTask(with: request as URLRequest, from: requestBody, completionHandler: { (_, _, error) in + completion?(error as NSError?) + }) + + task.resume() + } + + // MARK: - Private Helpers + + /// Returns a new (randomized) Boundary String + /// + private func boundaryForRequest() -> String { + return "Boundary-" + UUID().uuidString + } + + /// Returns the Body for a Gravatar Upload OP. + /// + /// - Parameters: + /// - gravatarData: The NSData-Encoded Image + /// - account: The account that will get updated + /// - boundary: The request's Boundary String + /// + /// - Returns: A NSData instance, containing the Request's Payload. + /// + private func bodyWithGravatarData(_ gravatarData: Data, account: String, boundary: String) -> Data { + let body = NSMutableData() + + // Image Payload + body.appendString("--\(boundary)\r\n") + body.appendString("Content-Disposition: form-data; name=\(UploadParameters.imageKey); ") + body.appendString("filename=\(UploadParameters.filename)\r\n") + body.appendString("Content-Type: \(UploadParameters.contentType);\r\n\r\n") + body.append(gravatarData) + body.appendString("\r\n") + + // Account Payload + body.appendString("--\(boundary)\r\n") + body.appendString("Content-Disposition: form-data; name=\"\(UploadParameters.accountKey)\"\r\n\r\n") + body.appendString("\(account)\r\n") + + // EOF! + body.appendString("--\(boundary)--\r\n") + + return body as Data + } + + // MARK: - Private Structs + private struct UploadParameters { + // swiftlint:disable operator_usage_whitespace + static let endpointURL = "https://api.gravatar.com/v1/upload-image" + static let HTTPMethod = "POST" + static let contentType = "application/octet-stream" + static let filename = "profile.png" + static let imageKey = "filedata" + static let accountKey = "account" + // swiftlint:enable operator_usage_whitespace + } +} diff --git a/Modules/Sources/WordPressKit/HTTPAuthenticationAlertController.swift b/Modules/Sources/WordPressKit/HTTPAuthenticationAlertController.swift new file mode 100644 index 000000000000..aa110146a4e7 --- /dev/null +++ b/Modules/Sources/WordPressKit/HTTPAuthenticationAlertController.swift @@ -0,0 +1,104 @@ +import Foundation +import UIKit + +/// URLAuthenticationChallenge Handler: It's up to the Host App to actually use this, whenever `WordPressOrgXMLRPCApi.onChallenge` is hit! +/// +open class HTTPAuthenticationAlertController { + + public typealias AuthenticationHandler = (URLSession.AuthChallengeDisposition, URLCredential?) -> Void + + private static var onGoingChallenges = [URLProtectionSpace: [AuthenticationHandler]]() + + static public func controller(for challenge: URLAuthenticationChallenge, handler: @escaping AuthenticationHandler) -> UIAlertController? { + if var handlers = onGoingChallenges[challenge.protectionSpace] { + handlers.append(handler) + onGoingChallenges[challenge.protectionSpace] = handlers + return nil + } + onGoingChallenges[challenge.protectionSpace] = [handler] + + switch challenge.protectionSpace.authenticationMethod { + case NSURLAuthenticationMethodServerTrust: + return controllerForServerTrustChallenge(challenge) + default: + return controllerForUserAuthenticationChallenge(challenge) + } + } + + static func executeHandlerForChallenge(_ challenge: URLAuthenticationChallenge, disposition: URLSession.AuthChallengeDisposition, credential: URLCredential?) { + guard let handlers = onGoingChallenges[challenge.protectionSpace] else { + return + } + for handler in handlers { + handler(disposition, credential) + } + onGoingChallenges.removeValue(forKey: challenge.protectionSpace) + } + + private static func controllerForServerTrustChallenge(_ challenge: URLAuthenticationChallenge) -> UIAlertController { + let title = NSLocalizedString("Certificate error", comment: "Popup title for wrong SSL certificate.") + let localizedMessage = NSLocalizedString( + "The certificate for this server is invalid. You might be connecting to a server that is pretending to be “%@” which could put your confidential information at risk.\n\nWould you like to trust the certificate anyway?", + comment: "Message for when the certificate for the server is invalid. The %@ placeholder will be replaced the a host name, received from the API." + ) + let message = String(format: localizedMessage, challenge.protectionSpace.host) + let controller = UIAlertController(title: title, message: message, preferredStyle: .alert) + + let cancelAction = UIAlertAction(title: NSLocalizedString("Cancel", comment: "Cancel button label"), + style: .default, + handler: { (_) in + executeHandlerForChallenge(challenge, disposition: .cancelAuthenticationChallenge, credential: nil) + }) + controller.addAction(cancelAction) + + let trustAction = UIAlertAction(title: NSLocalizedString("Trust", comment: "Connect when the SSL certificate is invalid"), + style: .default, + handler: { (_) in + let credential = URLCredential(trust: challenge.protectionSpace.serverTrust!) + URLCredentialStorage.shared.setDefaultCredential(credential, for: challenge.protectionSpace) + executeHandlerForChallenge(challenge, disposition: .useCredential, credential: credential) + }) + controller.addAction(trustAction) + return controller + } + + private static func controllerForUserAuthenticationChallenge(_ challenge: URLAuthenticationChallenge) -> UIAlertController { + let title = String(format: NSLocalizedString("Authentication required for host: %@", comment: "Popup title to ask for user credentials."), challenge.protectionSpace.host) + let message = NSLocalizedString("Please enter your credentials", comment: "Popup message to ask for user credentials (fields shown below).") + let controller = UIAlertController(title: title, + message: message, + preferredStyle: .alert) + + controller.addTextField( configurationHandler: { (textField) in + textField.placeholder = NSLocalizedString("Username", comment: "Login dialog username placeholder") + }) + + controller.addTextField(configurationHandler: { (textField) in + textField.placeholder = NSLocalizedString("Password", comment: "Login dialog password placeholder") + textField.isSecureTextEntry = true + }) + + let cancelAction = UIAlertAction(title: NSLocalizedString("Cancel", comment: "Cancel button label"), + style: .default, + handler: { (_) in + executeHandlerForChallenge(challenge, disposition: .cancelAuthenticationChallenge, credential: nil) + }) + controller.addAction(cancelAction) + + let loginAction = UIAlertAction(title: NSLocalizedString("Log In", comment: "Log In button label."), + style: .default, + handler: { (_) in + guard let username = controller.textFields?.first?.text, + let password = controller.textFields?.last?.text else { + executeHandlerForChallenge(challenge, disposition: .cancelAuthenticationChallenge, credential: nil) + return + } + let credential = URLCredential(user: username, password: password, persistence: URLCredential.Persistence.permanent) + URLCredentialStorage.shared.setDefaultCredential(credential, for: challenge.protectionSpace) + executeHandlerForChallenge(challenge, disposition: .useCredential, credential: credential) + }) + controller.addAction(loginAction) + return controller + } + +} diff --git a/Modules/Sources/WordPressKit/HTTPClient.swift b/Modules/Sources/WordPressKit/HTTPClient.swift new file mode 100644 index 000000000000..258f9e823806 --- /dev/null +++ b/Modules/Sources/WordPressKit/HTTPClient.swift @@ -0,0 +1,354 @@ +import Foundation +import Combine + +public typealias WordPressAPIResult = Result> + +public struct HTTPAPIResponse { + public var response: HTTPURLResponse + public var body: Body +} + +extension HTTPAPIResponse where Body == Data { + var bodyText: String? { + var encoding: String.Encoding? + if let charset = response.textEncodingName { + encoding = String.Encoding(ianaCharsetName: charset) + } + + let defaultEncoding = String.Encoding.isoLatin1 + return String(data: body, encoding: encoding ?? defaultEncoding) + } +} + +extension URLSession { + + /// Create a background URLSession instance that can be used in the `perform(request:...)` async function. + /// + /// The `perform(request:...)` async function can be used in all non-background `URLSession` instances without any + /// extra work. However, there is a requirement to make the function works with with background `URLSession` instances. + /// That is the `URLSession` must have a delegate of `BackgroundURLSessionDelegate` type. + static func backgroundSession(configuration: URLSessionConfiguration) -> URLSession { + assert(configuration.identifier != nil) + // Pass `delegateQueue: nil` to get a serial queue, which is required to ensure thread safe access to + // `WordPressKitSessionDelegate` instances. + return URLSession(configuration: configuration, delegate: BackgroundURLSessionDelegate(), delegateQueue: nil) + } + + /// Send a HTTP request and return its response as a `WordPressAPIResult` instance. + /// + /// ## Progress Tracking and Cancellation + /// + /// You can track the HTTP request's overall progress by passing a `Progress` instance to the `fulfillingProgress` + /// parameter, which must satisify following requirements: + /// - `totalUnitCount` must not be zero. + /// - `completedUnitCount` must be zero. + /// - It's used exclusivity for tracking the HTTP request overal progress: No children in its progress tree. + /// - `cancellationHandler` must be nil. You can call `fulfillingProgress.cancel()` to cancel the ongoing HTTP request. + /// + /// Upon completion, the HTTP request's progress fulfills the `fulfillingProgress`. + /// + /// - Parameters: + /// - builder: A `HTTPRequestBuilder` instance that represents an HTTP request to be sent. + /// - acceptableStatusCodes: HTTP status code ranges that are considered a successful response. Responses with + /// a status code outside of these ranges are returned as a `WordPressAPIResult.unacceptableStatusCode` instance. + /// - parentProgress: A `Progress` instance that will be used as the parent progress of the HTTP request's overall + /// progress. See the function documentation regarding requirements on this argument. + /// - errorType: The concret endpoint error type. + func perform( + request builder: HTTPRequestBuilder, + acceptableStatusCodes: [ClosedRange] = [200...299], + taskCreated: ((Int) -> Void)? = nil, + fulfilling parentProgress: Progress? = nil, + errorType: E.Type = E.self + ) async -> WordPressAPIResult, E> { + if configuration.identifier != nil { + assert(delegate is BackgroundURLSessionDelegate, "Unexpected `URLSession` delegate type. See the `backgroundSession(configuration:)`") + } + + if let parentProgress { + assert(parentProgress.completedUnitCount == 0 && parentProgress.totalUnitCount > 0, "Invalid parent progress") + assert(parentProgress.cancellationHandler == nil, "The progress instance's cancellationHandler property must be nil") + } + + let taskHolder = TaskHolder() + return await withTaskCancellationHandler { + await withCheckedContinuation { continuation in + let completion: @Sendable (Data?, URLResponse?, Error?) -> Void = { data, response, error in + let result: WordPressAPIResult, E> = Self.parseResponse( + data: data, + response: response, + error: error, + acceptableStatusCodes: acceptableStatusCodes + ) + + continuation.resume(returning: result) + } + + let task: URLSessionTask + + do { + task = try self.task(for: builder, completion: completion) + } catch { + continuation.resume(returning: .failure(.requestEncodingFailure(underlyingError: error))) + return + } + + task.resume() + taskCreated?(task.taskIdentifier) + Task { await taskHolder.assign(task) } + + if let parentProgress, parentProgress.totalUnitCount > parentProgress.completedUnitCount { + let pending = parentProgress.totalUnitCount - parentProgress.completedUnitCount + // The Jetpack/WordPress app requires task progress updates to be delievered on the main queue. + let progressUpdator = parentProgress.update(totalUnit: pending, with: task.progress, queue: .main) + + parentProgress.cancellationHandler = { [weak task] in + task?.cancel() + progressUpdator.cancel() + } + } + } + } onCancel: { + Task { await taskHolder.cancel() } + } + } + + private func task( + for builder: HTTPRequestBuilder, + completion originalCompletion: @escaping @Sendable (Data?, URLResponse?, Error?) -> Void + ) throws -> URLSessionTask { + var request = try builder.build(encodeBody: false) + + // This additional `callCompletionFromDelegate` is added to unit test `BackgroundURLSessionDelegate`. + // Background `URLSession` doesn't work on unit tests, we have to create a non-background `URLSession` + // which has a `BackgroundURLSessionDelegate` delegate in order to test `BackgroundURLSessionDelegate`. + // + // In reality, `callCompletionFromDelegate` and `isBackgroundSession` have the same value. + let callCompletionFromDelegate = delegate is BackgroundURLSessionDelegate + let isBackgroundSession = configuration.identifier != nil + let task: URLSessionTask + let body = try builder.encodeMultipartForm(request: &request, forceWriteToFile: isBackgroundSession) + ?? builder.encodeXMLRPC(request: &request, forceWriteToFile: isBackgroundSession) + var completion = originalCompletion + if let body { + // Use special `URLSession.uploadTask` API for multipart POST requests. + task = body.map( + left: { + if callCompletionFromDelegate { + return uploadTask(with: request, from: $0) + } else { + return uploadTask(with: request, from: $0, completionHandler: completion) + } + }, + right: { tempFileURL in + // Remove the temp file, which contains request body, once the HTTP request completes. + completion = { data, response, error in + try? FileManager.default.removeItem(at: tempFileURL) + originalCompletion(data, response, error) + } + + if callCompletionFromDelegate { + return uploadTask(with: request, fromFile: tempFileURL) + } else { + return uploadTask(with: request, fromFile: tempFileURL, completionHandler: completion) + } + } + ) + } else { + // Use `URLSession.dataTask` for all other request + if callCompletionFromDelegate { + task = dataTask(with: request) + } else { + task = dataTask(with: request, completionHandler: completion) + } + } + + if callCompletionFromDelegate { + assert(delegate is BackgroundURLSessionDelegate, "Unexpected `URLSession` delegate type. See the `backgroundSession(configuration:)`") + + set(completion: completion, forTaskWithIdentifier: task.taskIdentifier) + } + + return task + } + + private static func parseResponse( + data: Data?, + response: URLResponse?, + error: Error?, + acceptableStatusCodes: [ClosedRange] + ) -> WordPressAPIResult, E> { + let result: WordPressAPIResult, E> + + if let error { + if let urlError = error as? URLError { + result = .failure(.connection(urlError)) + } else { + result = .failure(.unknown(underlyingError: error)) + } + } else { + if let httpResponse = response as? HTTPURLResponse { + if acceptableStatusCodes.contains(where: { $0 ~= httpResponse.statusCode }) { + result = .success(HTTPAPIResponse(response: httpResponse, body: data ?? Data())) + } else { + result = .failure(.unacceptableStatusCode(response: httpResponse, body: data ?? Data())) + } + } else { + result = .failure(.unparsableResponse(response: nil, body: data)) + } + } + + return result + } + +} + +extension WordPressAPIResult { + + func mapSuccess( + _ transform: (Success) throws -> NewSuccess + ) -> WordPressAPIResult where Success == HTTPAPIResponse, Failure == WordPressAPIError { + flatMap { success in + do { + return try .success(transform(success)) + } catch { + return .failure(.unparsableResponse(response: success.response, body: success.body, underlyingError: error)) + } + } + } + + func decodeSuccess( + _ decoder: JSONDecoder = JSONDecoder(), + type: NewSuccess.Type = NewSuccess.self + ) -> WordPressAPIResult where Success == HTTPAPIResponse, Failure == WordPressAPIError { + mapSuccess { + try decoder.decode(type, from: $0.body) + } + } + + func mapUnacceptableStatusCodeError( + _ transform: (HTTPURLResponse, Data) throws -> E + ) -> WordPressAPIResult where Failure == WordPressAPIError { + mapError { error in + if case let .unacceptableStatusCode(response, body) = error { + do { + return try WordPressAPIError.endpointError(transform(response, body)) + } catch { + return WordPressAPIError.unparsableResponse(response: response, body: body, underlyingError: error) + } + } + return error + } + } + + func mapUnacceptableStatusCodeError( + _ decoder: JSONDecoder = JSONDecoder() + ) -> WordPressAPIResult where E: LocalizedError, E: Decodable, Failure == WordPressAPIError { + mapUnacceptableStatusCodeError { _, body in + try decoder.decode(E.self, from: body) + } + } + +} + +extension Progress { + func update(totalUnit: Int64, with progress: Progress, queue: DispatchQueue) -> AnyCancellable { + let start = self.completedUnitCount + return progress.publisher(for: \.fractionCompleted, options: .new) + .receive(on: queue) + .sink { [weak self] fraction in + self?.completedUnitCount = start + Int64(fraction * Double(totalUnit)) + } + } +} + +// MARK: - Background URL Session Support + +private final class SessionTaskData { + var responseBody = Data() + var completion: ((Data?, URLResponse?, Error?) -> Void)? +} + +class BackgroundURLSessionDelegate: NSObject, URLSessionDataDelegate { + + private var taskData = [Int: SessionTaskData]() + + func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) { + session.received(data, forTaskWithIdentifier: dataTask.taskIdentifier) + } + + func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { + session.completed(with: error, response: task.response, forTaskWithIdentifier: task.taskIdentifier) + } + +} + +private extension URLSession { + + static var taskDataKey = 0 + + // A map from `URLSessionTask` identifier to in-memory data of the given task. + // + // This property is in `URLSession` not `BackgroundURLSessionDelegate` because task id (the key) is unique within + // the context of a `URLSession` instance. And in theory `BackgroundURLSessionDelegate` can be used by multiple + // `URLSession` instances. + var taskData: [Int: SessionTaskData] { + get { + objc_getAssociatedObject(self, &URLSession.taskDataKey) as? [Int: SessionTaskData] ?? [:] + } + set { + objc_setAssociatedObject(self, &URLSession.taskDataKey, newValue, .OBJC_ASSOCIATION_RETAIN) + } + } + + func updateData(forTaskWithIdentifier taskID: Int, using closure: (SessionTaskData) -> Void) { + let task = self.taskData[taskID] ?? SessionTaskData() + closure(task) + self.taskData[taskID] = task + } + + func set(completion: @escaping (Data?, URLResponse?, Error?) -> Void, forTaskWithIdentifier taskID: Int) { + updateData(forTaskWithIdentifier: taskID) { + $0.completion = completion + } + } + + func received(_ data: Data, forTaskWithIdentifier taskID: Int) { + updateData(forTaskWithIdentifier: taskID) { task in + task.responseBody.append(data) + } + } + + func completed(with error: Error?, response: URLResponse?, forTaskWithIdentifier taskID: Int) { + guard let task = taskData[taskID] else { + return + } + + if let error { + task.completion?(nil, response, error) + } else { + task.completion?(task.responseBody, response, nil) + } + + self.taskData.removeValue(forKey: taskID) + } + +} + +extension URLSession { + var debugNumberOfTaskData: Int { + self.taskData.count + } +} + +private actor TaskHolder { + weak var task: URLSessionTask? + + func assign(_ task: URLSessionTask) { + self.task = task + } + + func cancel() { + task?.cancel() + } +} diff --git a/Modules/Sources/WordPressKit/HTTPProtocolHelpers.swift b/Modules/Sources/WordPressKit/HTTPProtocolHelpers.swift new file mode 100644 index 000000000000..b446e31d8cdb --- /dev/null +++ b/Modules/Sources/WordPressKit/HTTPProtocolHelpers.swift @@ -0,0 +1,61 @@ +import Foundation + +extension HTTPURLResponse { + + /// Return parameter value in a header field. + /// + /// For example, you can use this method to get "charset" value from a 'Content-Type' header like + /// `Content-Type: applications/json; charset=utf-8`. + func value(ofParameter parameterName: String, inHeaderField headerName: String, stripQuotes: Bool = true) -> String? { + guard let headerValue = value(forHTTPHeaderField: headerName) else { + return nil + } + + return Self.value(ofParameter: parameterName, inHeaderValue: headerValue, stripQuotes: stripQuotes) + } + + func value(forHTTPHeaderField field: String, withoutParameters: Bool) -> String? { + guard withoutParameters else { + return value(forHTTPHeaderField: field) + } + + guard let headerValue = value(forHTTPHeaderField: field) else { + return nil + } + + guard let firstSemicolon = headerValue.firstIndex(of: ";") else { + return headerValue + } + + return String(headerValue[headerValue.startIndex.. String? { + // Find location of '=' string in the header. + guard let location = headerValue.range(of: parameterName + "=", options: .caseInsensitive) else { + return nil + } + + let parameterValueStart = location.upperBound + let parameterValueEnd: String.Index + + // ';' marks the end of the parameter value. + if let found = headerValue.range(of: ";", range: parameterValueStart.. Void)? + private(set) var multipartForm: [MultipartFormField]? + private(set) var xmlrpcRequest: XMLRPCRequest? + + init(url: URL) { + assert(url.scheme == "http" || url.scheme == "https") + assert(url.host != nil) + + original = URLComponents(url: url, resolvingAgainstBaseURL: true)! + } + + func method(_ method: Method) -> Self { + self.method = method + return self + } + + /// Append path to the original URL. + /// + /// The argument will be appended to the original URL as it is. + func append(percentEncodedPath path: String) -> Self { + assert(!path.contains("?") && !path.contains("#"), "Path should not have query or fragment: \(path)") + + appendedPath = Self.join(appendedPath, path) + + return self + } + + /// Append path and query to the original URL. + /// + /// Some may call API client using a string that contains path and query, like `api.get("post?id=1")`. + /// This function can be used to support those use cases. + func appendURLString(_ string: String) -> Self { + let urlString = Self.join("https://w.org", string) + guard let url = URL(string: urlString), + let urlComponents = URLComponents(url: url, resolvingAgainstBaseURL: true) + else { + assertionFailure("Illegal URL string: \(string)") + return self + } + + return append(percentEncodedPath: urlComponents.percentEncodedPath) + .append(query: urlComponents.queryItems ?? []) + } + + func headers(_ headers: [String: String]) -> Self { + for (key, value) in headers { + self.headers[key] = value + } + return self + } + + func header(name: String, value: String?) -> Self { + headers[name] = value + return self + } + + func query(defaults: [URLQueryItem]) -> Self { + defaultQuery = defaults + return self + } + + func query(name: String, value: String?, override: Bool = false) -> Self { + append(query: [URLQueryItem(name: name, value: value)], override: override) + } + + func query(_ parameters: [String: Any]) -> Self { + append(query: parameters.flatten(), override: false) + } + + func append(query: [URLQueryItem], override: Bool = false) -> Self { + if override { + let newKeys = Set(query.map { $0.name }) + appendedQuery.removeAll(where: { newKeys.contains($0.name) }) + } + + appendedQuery.append(contentsOf: query) + + return self + } + + func body(form: [String: Any]) -> Self { + headers["Content-Type"] = "application/x-www-form-urlencoded; charset=utf-8" + bodyBuilder = { req in + let content = form.flatten().percentEncoded + req.httpBody = content.data(using: .utf8) + } + return self + } + + func body(form: [MultipartFormField]) -> Self { + // Unlike other similar functions, multipart form encoding is handled by the `build` function. + multipartForm = form + return self + } + + func body(json: Encodable, jsonEncoder: JSONEncoder = JSONEncoder()) -> Self { + body(json: { + try jsonEncoder.encode(json) + }) + } + + func body(json: Any) -> Self { + body(json: { + try JSONSerialization.data(withJSONObject: json) + }) + } + + func body(json: @escaping () throws -> Data) -> Self { + // 'charset' parameter is not required for json body. See https://www.rfc-editor.org/rfc/rfc8259.html#section-11 + headers["Content-Type"] = "application/json" + bodyBuilder = { req in + req.httpBody = try json() + } + return self + } + + func body(xml: @escaping () throws -> Data) -> Self { + headers["Content-Type"] = "text/xml; charset=utf-8" + bodyBuilder = { req in + req.httpBody = try xml() + } + return self + } + + func build(encodeBody: Bool = false) throws -> URLRequest { + var components = original + + var newPath = Self.join(components.percentEncodedPath, appendedPath) + if !newPath.isEmpty, !newPath.hasPrefix("/") { + newPath = "/\(newPath)" + } + components.percentEncodedPath = newPath + + // Add default query items if they don't exist in `appendedQuery`. + var newQuery = appendedQuery + if !defaultQuery.isEmpty { + let allQuery = (original.queryItems ?? []) + newQuery + let toBeAdded = defaultQuery.filter { item in + !allQuery.contains(where: { $0.name == item.name}) + } + newQuery.append(contentsOf: toBeAdded) + } + + // Bypass `URLComponents`'s URL query encoding, use our own implementation instead. + if !newQuery.isEmpty { + components.percentEncodedQuery = Self.join(components.percentEncodedQuery ?? "", newQuery.percentEncoded, separator: "&") + } + + guard let url = components.url else { + throw URLError(.badURL) + } + + var request = URLRequest(url: url) + request.httpMethod = method.rawValue + + for (header, value) in headers { + request.addValue(value, forHTTPHeaderField: header) + } + + if encodeBody { + let body = try encodeMultipartForm(request: &request, forceWriteToFile: false) ?? encodeXMLRPC(request: &request, forceWriteToFile: false) + if let body { + switch body { + case let .left(data): + request.httpBody = data + case let .right(url): + request.httpBodyStream = InputStream(url: url) + } + } + } + + if let bodyBuilder { + assert(method.allowsHTTPBody, "Can't include body in HTTP \(method.rawValue) requests") + try bodyBuilder(&request) + } + + return request + } + + func encodeMultipartForm(request: inout URLRequest, forceWriteToFile: Bool) throws -> Either? { + guard let multipartForm, !multipartForm.isEmpty else { + return nil + } + + let boundery = String(format: "wordpresskit.%08x", Int.random(in: Int.min.. Either? { + guard let xmlrpcRequest else { + return nil + } + + request.setValue("text/xml", forHTTPHeaderField: "Content-Type") + let encoder = WPXMLRPCEncoder(method: xmlrpcRequest.method, andParameters: xmlrpcRequest.parameters) + if forceWriteToFile { + let fileName = "\(UUID().uuidString).xmlrpc" + let fileURL = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(fileName) + try encoder.encode(toFile: fileURL.path) + + var fileSize: AnyObject? + try (fileURL as NSURL).getResourceValue(&fileSize, forKey: .fileSizeKey) + if let fileSize = fileSize as? NSNumber { + request.setValue(fileSize.stringValue, forHTTPHeaderField: "Content-Length") + } + + return .right(fileURL) + } else { + let data = try encoder.dataEncoded() + request.setValue("\(data.count)", forHTTPHeaderField: "Content-Length") + return .left(data) + } + } +} + +extension HTTPRequestBuilder { + func body(xmlrpc method: String, parameters: [Any]? = nil) -> Self { + self.xmlrpcRequest = XMLRPCRequest(method: method, parameters: parameters) + return self + } +} + +extension HTTPRequestBuilder { + static func urlEncode(_ text: String) -> String { + let specialCharacters = ":#[]@!$&'()*+,;=" + let allowed = CharacterSet.urlQueryAllowed.subtracting(.init(charactersIn: specialCharacters)) + return text.addingPercentEncoding(withAllowedCharacters: allowed) ?? text + } + + /// Join a list of strings using a separator only if neighbour items aren't already separated with the given separator. + static func join(_ aList: String..., separator: String = "/") -> String { + guard !aList.isEmpty else { return "" } + + var list = aList + let start = list.removeFirst() + return list.reduce(into: start) { result, path in + guard !path.isEmpty else { return } + + guard !result.isEmpty else { + result = path + return + } + + switch (result.hasSuffix(separator), path.hasPrefix(separator)) { + case (true, true): + var prefixRemoved = path + prefixRemoved.removePrefix(separator) + result.append(prefixRemoved) + case (true, false), (false, true): + result.append(path) + case (false, false): + result.append("\(separator)\(path)") + } + } + } +} + +private extension Dictionary where Key == String, Value == Any { + + static func urlEncode(into result: inout [URLQueryItem], name: String, value: Any) { + switch value { + case let array as [Any]: + for value in array { + urlEncode(into: &result, name: "\(name)[]", value: value) + } + case let object as [String: Any]: + for (key, value) in object { + urlEncode(into: &result, name: "\(name)[\(key)]", value: value) + } + case let value as Bool: + urlEncode(into: &result, name: name, value: value ? "1" : "0") + default: + result.append(URLQueryItem(name: name, value: "\(value)")) + } + } + + func flatten() -> [URLQueryItem] { + sorted { $0.key < $1.key } + .reduce(into: []) { result, entry in + Self.urlEncode(into: &result, name: entry.key, value: entry.value) + } + } + +} + +extension Array where Element == URLQueryItem { + + var percentEncoded: String { + map { + let name = HTTPRequestBuilder.urlEncode($0.name) + guard let value = $0.value else { + return name + } + + return "\(name)=\(HTTPRequestBuilder.urlEncode(value))" + } + .joined(separator: "&") + } + +} + +struct XMLRPCRequest { + var method: String + var parameters: [Any]? +} diff --git a/Modules/Sources/WordPressKit/HomepageSettingsServiceRemote.swift b/Modules/Sources/WordPressKit/HomepageSettingsServiceRemote.swift new file mode 100644 index 000000000000..d56c5bfa4dc8 --- /dev/null +++ b/Modules/Sources/WordPressKit/HomepageSettingsServiceRemote.swift @@ -0,0 +1,43 @@ +import Foundation +import WordPressKitObjC + +public class HomepageSettingsServiceRemote: ServiceRemoteWordPressComREST { + + /** + Sets the homepage type for the specified site. + - Parameters: + - type: The type of homepage to use: blog posts (.posts), or static pages (.page). + - siteID: The ID of the site to update + - postsPageID: The ID of the page to use as the blog page if the homepage type is .page + - homePageID: The ID of the page to use as the homepage is the homepage type is .pag + - success: Completion block called after the settings have been successfully updated + - failure: Failure block called if settings were not successfully updated + */ + public func setHomepageType(type: RemoteHomepageType, for siteID: Int, withPostsPageID postsPageID: Int? = nil, homePageID: Int? = nil, success: @escaping () -> Void, failure: @escaping (Error) -> Void) { + let endpoint = "sites/\(siteID)/homepage" + let path = self.path(forEndpoint: endpoint, withVersion: ._1_1) + + var parameters: [String: AnyObject] = [Keys.isPageOnFront: type.isPageOnFront as AnyObject] + + if let homePageID { + parameters[Keys.pageOnFrontID] = homePageID as AnyObject + } + + if let postsPageID { + parameters[Keys.pageForPostsID] = postsPageID as AnyObject + } + + wordPressComRESTAPI.post(path, parameters: parameters, + success: { _, _ in + success() + }, failure: { error, _ in + failure(error) + }) + } + + private enum Keys { + static let isPageOnFront = "is_page_on_front" + static let pageOnFrontID = "page_on_front_id" + static let pageForPostsID = "page_for_posts_id" + } +} diff --git a/Modules/Sources/WordPressKit/IPLocationRemote.swift b/Modules/Sources/WordPressKit/IPLocationRemote.swift new file mode 100644 index 000000000000..ad65ee6eaeed --- /dev/null +++ b/Modules/Sources/WordPressKit/IPLocationRemote.swift @@ -0,0 +1,51 @@ +import Foundation + +/// Remote type to fetch the user's IP Location using the public `geo` API. +/// +public final class IPLocationRemote { + private enum Constants { + static let jsonDecoder = JSONDecoder() + } + + private let urlSession: URLSession + + public init(urlSession: URLSession = URLSession.shared) { + self.urlSession = urlSession + } + + /// Fetches the country code from the device ip. + /// + public func fetchIPCountryCode(completion: @escaping (Result) -> Void) { + let url = WordPressComOAuthClient.WordPressComOAuthDefaultApiBaseURL.appendingPathComponent("geo/") + + let request = URLRequest(url: url) + let task = urlSession.dataTask(with: request) { data, _, error in + guard let data else { + completion(.failure(IPLocationError.requestFailure(error))) + return + } + + do { + let result = try Constants.jsonDecoder.decode(RemoteIPCountryCode.self, from: data) + completion(.success(result.countryCode)) + } catch { + completion(.failure(error)) + } + } + task.resume() + } +} + +public extension IPLocationRemote { + enum IPLocationError: Error { + case requestFailure(Error?) + } +} + +public struct RemoteIPCountryCode: Decodable { + enum CodingKeys: String, CodingKey { + case countryCode = "country_short" + } + + let countryCode: String +} diff --git a/Modules/Sources/WordPressKit/JSONDecoderExtension.swift b/Modules/Sources/WordPressKit/JSONDecoderExtension.swift new file mode 100644 index 000000000000..050dcbce7522 --- /dev/null +++ b/Modules/Sources/WordPressKit/JSONDecoderExtension.swift @@ -0,0 +1,52 @@ +import Foundation + +extension JSONDecoder { + + static var apiDecoder: JSONDecoder { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = JSONDecoder.DateDecodingStrategy.supportMultipleDateFormats + decoder.keyDecodingStrategy = .convertFromSnakeCase + return decoder + } +} + +extension JSONDecoder.DateDecodingStrategy { + + enum DateFormat: String, CaseIterable { + case noTime = "yyyy-mm-dd" + case dateWithTime = "yyyy-MM-dd HH:mm:ss" + case iso8601 = "yyyy-MM-dd'T'HH:mm:ssZ" + case iso8601WithMilliseconds = "yyyy-MM-dd'T'HH:mm:ss.SSSXXXXX" + + var formatter: DateFormatter { + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = rawValue + return dateFormatter + } + } + + static var supportMultipleDateFormats: JSONDecoder.DateDecodingStrategy { + return JSONDecoder.DateDecodingStrategy.custom({ (decoder) -> Date in + let container = try decoder.singleValueContainer() + let dateStr = try container.decode(String.self) + + var date: Date? + + for format in DateFormat.allCases { + date = format.formatter.date(from: dateStr) + if date != nil { + break + } + } + + guard let calculatedDate = date else { + throw DecodingError.dataCorruptedError( + in: container, + debugDescription: "Cannot decode date string \(dateStr)" + ) + } + + return calculatedDate + }) + } +} diff --git a/Modules/Sources/WordPressKit/JetpackAIServiceRemote.swift b/Modules/Sources/WordPressKit/JetpackAIServiceRemote.swift new file mode 100644 index 000000000000..501cfcf0fb4d --- /dev/null +++ b/Modules/Sources/WordPressKit/JetpackAIServiceRemote.swift @@ -0,0 +1,86 @@ +import Foundation + +public final class JetpackAIServiceRemote: SiteServiceRemoteWordPressComREST { + + /// Returns information about your current tier, requests limit, and more. + public func getAssistantFeatureDetails() async throws -> JetpackAssistantFeatureDetails { + let path = path(forEndpoint: "sites/\(siteID)/jetpack-ai/ai-assistant-feature", withVersion: ._2_0) + let response = await wordPressComRestApi.perform(.get, URLString: path, type: JetpackAssistantFeatureDetails.self) + return try response.get().body + } + + /// Returns short-lived JWT token (lifetime is in minutes). + public func getAuthorizationToken() async throws -> String { + struct Response: Decodable { + let token: String + } + let path = path(forEndpoint: "sites/\(siteID)/jetpack-openai-query/jwt", withVersion: ._2_0) + let response = await wordPressComRestApi.perform(.post, URLString: path, type: Response.self) + return try response.get().body.token + } + + /// - parameter token: Token retrieved using ``JetpackAIServiceRemote/getAuthorizationToken``. + public func transcribeAudio(from fileURL: URL, token: String) async throws -> String { + let path = path(forEndpoint: "jetpack-ai-transcription?feature=voice-to-content", withVersion: ._2_0) + let file = FilePart(parameterName: "audio_file", url: fileURL, fileName: "voice_recording", mimeType: "audio/m4a") + let result = await wordPressComRestApi.upload(URLString: path, httpHeaders: [ + "Authorization": "Bearer \(token)" + ], fileParts: [file]) + guard let body = try result.get().body as? [String: Any], + let text = body["text"] as? String else { + throw URLError(.unknown) + } + return text + } + + /// - parameter token: Token retrieved using ``JetpackAIServiceRemote/getAuthorizationToken``. + public func makePostContent(fromPlainText plainText: String, token: String) async throws -> String { + let path = path(forEndpoint: "jetpack-ai-query", withVersion: ._2_0) + let request = JetpackAIQueryRequest(messages: [ + .init(role: "jetpack-ai", context: .init(type: "voice-to-content-simple-draft", content: plainText)) + ], feature: "voice-to-content", stream: false) + let builder = try wordPressComRestApi.requestBuilder(URLString: path) + .method(.post) + .headers(["Authorization": "Bearer \(token)"]) + .body(json: request, jsonEncoder: JSONEncoder()) + let result = await wordPressComRestApi.perform(request: builder) { data in + try JSONDecoder().decode(JetpackAIQueryResponse.self, from: data) + } + let response = try result.get().body + guard let content = response.choices.first?.message.content else { + throw URLError(.unknown) + } + return content + } +} + +private struct JetpackAIQueryRequest: Encodable { + let messages: [Message] + let feature: String + let stream: Bool + + struct Message: Encodable { + let role: String + let context: Context + } + + struct Context: Codable { + let type: String + let content: String + } +} + +private struct JetpackAIQueryResponse: Decodable { + let model: String? + let choices: [Choice] + + struct Choice: Codable { + let index: Int + let message: Message + } + + struct Message: Codable { + let role: String? + let content: String + } +} diff --git a/Modules/Sources/WordPressKit/JetpackAssistantFeatureDetails.swift b/Modules/Sources/WordPressKit/JetpackAssistantFeatureDetails.swift new file mode 100644 index 000000000000..9089fada370f --- /dev/null +++ b/Modules/Sources/WordPressKit/JetpackAssistantFeatureDetails.swift @@ -0,0 +1,79 @@ +import Foundation + +public final class JetpackAssistantFeatureDetails: Codable { + public let hasFeature: Bool + /// Returns `true` if you are out of limit for the current plan. + public let isOverLimit: Bool + /// The all-time request count. + public let requestsCount: Int + /// The request limit for a free plan. + public let requestsLimit: Int + /// Contains data about the user plan. + public let currentTier: Tier? + public let usagePeriod: UsagePeriod? + public let isSiteUpdateRequired: Bool? + public let upgradeType: String? + public let upgradeURL: String? + public let nextTier: Tier? + public let tierPlans: [Tier]? + public let tierPlansEnabled: Bool? + public let costs: Costs? + + public struct Tier: Codable { + public let slug: String? + public let limit: Int + public let value: Int + public let readableLimit: String? + + enum CodingKeys: String, CodingKey { + case slug, limit, value + case readableLimit = "readable-limit" + } + } + + public struct UsagePeriod: Codable { + public let currentStart: String? + public let nextStart: String? + public let requestsCount: Int + + enum CodingKeys: String, CodingKey { + case currentStart = "current-start" + case nextStart = "next-start" + case requestsCount = "requests-count" + } + } + + public struct Costs: Codable { + public let jetpackAILogoGenerator: JetpackAILogoGenerator + public let featuredPostImage: FeaturedPostImage + + enum CodingKeys: String, CodingKey { + case jetpackAILogoGenerator = "jetpack-ai-logo-generator" + case featuredPostImage = "featured-post-image" + } + } + + public struct FeaturedPostImage: Codable { + public let image: Int + } + + public struct JetpackAILogoGenerator: Codable { + public let logo: Int + } + + enum CodingKeys: String, CodingKey { + case hasFeature = "has-feature" + case isOverLimit = "is-over-limit" + case requestsCount = "requests-count" + case requestsLimit = "requests-limit" + case usagePeriod = "usage-period" + case isSiteUpdateRequired = "site-require-upgrade" + case upgradeURL = "upgrade-url" + case upgradeType = "upgrade-type" + case currentTier = "current-tier" + case nextTier = "next-tier" + case tierPlans = "tier-plans" + case tierPlansEnabled = "tier-plans-enabled" + case costs + } +} diff --git a/Modules/Sources/WordPressKit/JetpackBackup.swift b/Modules/Sources/WordPressKit/JetpackBackup.swift new file mode 100644 index 000000000000..6bd56dc2e589 --- /dev/null +++ b/Modules/Sources/WordPressKit/JetpackBackup.swift @@ -0,0 +1,40 @@ +import Foundation + +public struct JetpackBackup: Decodable { + + // Common + public let backupPoint: Date + public let downloadID: Int + public let rewindID: String + public let startedAt: Date + + // Prepare backup + public let progress: Int? + + // Get backup status + public let downloadCount: Int? + public let url: String? + public let validUntil: Date? + + private enum CodingKeys: String, CodingKey { + case backupPoint + case downloadID = "downloadId" + case rewindID = "rewindId" + case startedAt + case progress + case downloadCount + case url + case validUntil + } + + public init(backupPoint: Date, downloadID: Int, rewindID: String, startedAt: Date, progress: Int?, downloadCount: Int?, url: String?, validUntil: Date?) { + self.backupPoint = backupPoint + self.downloadID = downloadID + self.rewindID = rewindID + self.startedAt = startedAt + self.progress = progress + self.downloadCount = downloadCount + self.url = url + self.validUntil = validUntil + } +} diff --git a/Modules/Sources/WordPressKit/JetpackBackupServiceRemote.swift b/Modules/Sources/WordPressKit/JetpackBackupServiceRemote.swift new file mode 100644 index 000000000000..0ef32b1f4fea --- /dev/null +++ b/Modules/Sources/WordPressKit/JetpackBackupServiceRemote.swift @@ -0,0 +1,136 @@ +import Foundation +import WordPressKitObjC + +open class JetpackBackupServiceRemote: ServiceRemoteWordPressComREST { + + /// Prepare a downloadable backup snapshot for a site. + /// + /// - Parameters: + /// - siteID: The target site's ID. + /// - rewindID: The rewindID of the snapshot to download. + /// - types: The types of items to restore. + /// - success: Closure to be executed on success. + /// - failure: Closure to be executed on error. + /// + /// - Returns: A backup snapshot object. + /// + open func prepareBackup(_ siteID: Int, + rewindID: String? = nil, + types: JetpackRestoreTypes? = nil, + success: @escaping (_ backup: JetpackBackup) -> Void, + failure: @escaping (Error) -> Void) { + let path = backupPath(for: siteID) + var parameters: [String: AnyObject] = [:] + + if let rewindID { + parameters["rewindId"] = rewindID as AnyObject + } + if let types { + parameters["types"] = types.toDictionary() as AnyObject + } + + wordPressComRESTAPI.post(path, parameters: parameters, success: { response, _ in + do { + let decoder = JSONDecoder.apiDecoder + let data = try JSONSerialization.data(withJSONObject: response, options: []) + let envelope = try decoder.decode(JetpackBackup.self, from: data) + success(envelope) + } catch { + failure(error) + } + }, failure: { error, _ in + failure(error) + }) + } + + /// Get the backup download status for a site and downloadID. + /// - Parameters: + /// - siteID: The target site's ID. + /// - downloadID: The download ID of the snapshot being downloaded. + /// - success: Closure to be executed on success. + /// - failure: Closure to be executed on error. + /// + /// - Returns: A backup snapshot object. + /// + open func getBackupStatus(_ siteID: Int, + downloadID: Int, + success: @escaping (_ backup: JetpackBackup) -> Void, + failure: @escaping (Error) -> Void) { + getDownloadStatus(siteID, downloadID: downloadID, success: success, failure: failure) + } + + /// Get the backup status for all the backups in a site. + /// - Parameters: + /// - siteID: The target site's ID. + /// - success: Closure to be executed on success. + /// - failure: Closure to be executed on error. + /// + /// - Returns: A backup snapshot object. + /// + open func getAllBackupStatus(_ siteID: Int, + success: @escaping (_ backup: [JetpackBackup]) -> Void, + failure: @escaping (Error) -> Void) { + getDownloadStatus(siteID, success: success, failure: failure) + } + + /// Mark a backup as dismissed + /// - Parameters: + /// - siteID: The target site's ID. + /// - downloadID: The download ID of the snapshot being downloaded. + /// - success: Closure to be executed on success. + /// - failure: Closure to be executed on error. + /// + open func markAsDismissed(_ siteID: Int, + downloadID: Int, + success: @escaping () -> Void, + failure: @escaping (Error) -> Void) { + let path = backupPath(for: siteID, with: "\(downloadID)") + + let parameters = ["dismissed": true] as [String: AnyObject] + + wordPressComRESTAPI.post(path, parameters: parameters, success: { _, _ in + success() + }, failure: { error, _ in + failure(error) + }) + } + + // MARK: - Private + + private func getDownloadStatus(_ siteID: Int, + downloadID: Int? = nil, + success: @escaping (_ backup: T) -> Void, + failure: @escaping (Error) -> Void) { + + let path: String + if let downloadID { + path = backupPath(for: siteID, with: "\(downloadID)") + } else { + path = backupPath(for: siteID) + } + + wordPressComRESTAPI.get(path, parameters: nil, success: { response, _ in + do { + let decoder = JSONDecoder.apiDecoder + let data = try JSONSerialization.data(withJSONObject: response, options: []) + let envelope = try decoder.decode(T.self, from: data) + success(envelope) + } catch { + failure(error) + } + }, failure: { error, _ in + failure(error) + }) + } + + private func backupPath(for siteID: Int, with path: String? = nil) -> String { + var endpoint = "sites/\(siteID)/rewind/downloads/" + + if let path { + endpoint = endpoint.appending(path) + } + + return self.path(forEndpoint: endpoint, withVersion: ._2_0) + } + +} diff --git a/Modules/Sources/WordPressKit/JetpackCapabilitiesServiceRemote.swift b/Modules/Sources/WordPressKit/JetpackCapabilitiesServiceRemote.swift new file mode 100644 index 000000000000..a2ef3bcfc687 --- /dev/null +++ b/Modules/Sources/WordPressKit/JetpackCapabilitiesServiceRemote.swift @@ -0,0 +1,42 @@ +import Foundation +import WordPressKitObjC + +/// A service that returns the Jetpack Capabilities for a set of blogs +open class JetpackCapabilitiesServiceRemote: ServiceRemoteWordPressComREST { + + /// Returns a Dictionary of capabilities for each given siteID + /// - Parameters: + /// - siteIds: an array of Int representing siteIDs + /// - success: a success block that accepts a dictionary as a parameter + open func `for`(siteIds: [Int], success: @escaping ([String: AnyObject]) -> Void) { + var jetpackCapabilities: [String: AnyObject] = [:] + let dispatchGroup = DispatchGroup() + let dispatchQueue = DispatchQueue(label: "com.rewind.capabilities") + + siteIds.forEach { siteID in + dispatchGroup.enter() + + dispatchQueue.async { + let endpoint = "sites/\(siteID)/rewind/capabilities" + let path = self.path(forEndpoint: endpoint, withVersion: ._2_0) + + self.wordPressComRESTAPI.get(path, + parameters: nil, + success: { response, _ in + if let capabilities = (response as? [String: AnyObject])?["capabilities"] as? [String] { + jetpackCapabilities["\(siteID)"] = capabilities as AnyObject + } + + dispatchGroup.leave() + }, failure: { _, _ in + dispatchGroup.leave() + }) + } + } + + dispatchGroup.notify(queue: .global(qos: .background)) { + success(jetpackCapabilities) + } + } + +} diff --git a/Modules/Sources/WordPressKit/JetpackCredentials.swift b/Modules/Sources/WordPressKit/JetpackCredentials.swift new file mode 100644 index 000000000000..76ac36233e19 --- /dev/null +++ b/Modules/Sources/WordPressKit/JetpackCredentials.swift @@ -0,0 +1,12 @@ +import Foundation + +/// A limited representation of the users Jetpack credentials +public struct JetpackScanCredentials: Decodable { + public let host: String? + public let port: Int? + public let user: String? + public let path: String? + public let type: String + public let role: String + public let stillValid: Bool +} diff --git a/Modules/Sources/WordPressKit/JetpackPluginManagementClient.swift b/Modules/Sources/WordPressKit/JetpackPluginManagementClient.swift new file mode 100644 index 000000000000..6d78facdba65 --- /dev/null +++ b/Modules/Sources/WordPressKit/JetpackPluginManagementClient.swift @@ -0,0 +1,47 @@ +import Foundation +public class JetpackPluginManagementClient: PluginManagementClient { + private let siteID: Int + private let remote: PluginServiceRemote + + public required init?(with siteID: Int, remote: PluginServiceRemote) { + self.siteID = siteID + self.remote = remote + } + + public func getPlugins(success: @escaping (SitePlugins) -> Void, failure: @escaping (Error) -> Void) { + remote.getPlugins(siteID: siteID, success: success, failure: failure) + } + + public func updatePlugin(pluginID: String, success: @escaping (PluginState) -> Void, failure: @escaping (Error) -> Void) { + remote.updatePlugin(pluginID: pluginID, siteID: siteID, success: success, failure: failure) + } + + public func activatePlugin(pluginID: String, success: @escaping () -> Void, failure: @escaping (Error) -> Void) { + remote.activatePlugin(pluginID: pluginID, siteID: siteID, success: success, failure: failure) + } + + public func deactivatePlugin(pluginID: String, success: @escaping () -> Void, failure: @escaping (Error) -> Void) { + remote.deactivatePlugin(pluginID: pluginID, siteID: siteID, success: success, failure: failure) + } + + public func enableAutoupdates(pluginID: String, success: @escaping () -> Void, failure: @escaping (Error) -> Void) { + remote.enableAutoupdates(pluginID: pluginID, siteID: siteID, success: success, failure: failure) + + } + + public func disableAutoupdates(pluginID: String, success: @escaping () -> Void, failure: @escaping (Error) -> Void) { + remote.disableAutoupdates(pluginID: pluginID, siteID: siteID, success: success, failure: failure) + } + + public func activateAndEnableAutoupdates(pluginID: String, success: @escaping () -> Void, failure: @escaping (Error) -> Void) { + remote.activateAndEnableAutoupdates(pluginID: pluginID, siteID: siteID, success: success, failure: failure) + } + + public func install(pluginSlug: String, success: @escaping (PluginState) -> Void, failure: @escaping (Error) -> Void) { + remote.install(pluginSlug: pluginSlug, siteID: siteID, success: success, failure: failure) + } + + public func remove(pluginID: String, success: @escaping () -> Void, failure: @escaping (Error) -> Void) { + remote.remove(pluginID: pluginID, siteID: siteID, success: success, failure: failure) + } +} diff --git a/Modules/Sources/WordPressKit/JetpackProxyServiceRemote.swift b/Modules/Sources/WordPressKit/JetpackProxyServiceRemote.swift new file mode 100644 index 000000000000..6c621eea8c1d --- /dev/null +++ b/Modules/Sources/WordPressKit/JetpackProxyServiceRemote.swift @@ -0,0 +1,61 @@ +import Foundation +import WordPressKitObjC + +/// Encapsulates Jetpack Proxy requests. +public class JetpackProxyServiceRemote: ServiceRemoteWordPressComREST { + + /// Represents the most common HTTP methods for the proxied request. + public enum DotComMethod: String { + case get + case post + case put + case delete + } + + /// Sends a proxied request to a Jetpack-connected site through the Jetpack Proxy API. + /// The proxy API expects the client to be authenticated with a WordPress.com account. + /// + /// - Parameters: + /// - siteID: The dotcom ID of the Jetpack-connected site. + /// - path: The request endpoint to be proxied. + /// - method: The HTTP method for the proxied request. + /// - parameters: The request parameter for the proxied request. Defaults to empty. + /// - locale: The user locale, if any. Defaults to nil. + /// - completion: Closure called after the request completes. + /// - Returns: A Progress object, which can be used to cancel the request if needed. + @discardableResult + public func proxyRequest(for siteID: Int, + path: String, + method: DotComMethod, + parameters: [String: AnyHashable] = [:], + locale: String? = nil, + completion: @escaping (Result) -> Void) -> Progress? { + let urlString = self.path(forEndpoint: "jetpack-blogs/\(siteID)/rest-api", withVersion: ._1_1) + + // Construct the request parameters to be forwarded to the actual endpoint. + var requestParams: [String: AnyHashable] = [ + "json": "true", + "path": "\(path)&_method=\(method.rawValue)" + ] + + // The parameters need to be encoded into a JSON string. + if !parameters.isEmpty, + let data = try? JSONSerialization.data(withJSONObject: parameters, options: []), + let jsonString = String(data: data, encoding: .utf8) { + // Use "query" for the body parameters if the method is GET. Otherwise, always use "body". + let bodyParameterKey = (method == .get ? "query" : "body") + requestParams[bodyParameterKey] = jsonString + } + + if let locale, + !locale.isEmpty { + requestParams["locale"] = locale + } + + return wordPressComRESTAPI.post(urlString, parameters: requestParams) { response, _ in + completion(.success(response)) + } failure: { error, _ in + completion(.failure(error)) + } + } +} diff --git a/Modules/Sources/WordPressKit/JetpackRestoreTypes.swift b/Modules/Sources/WordPressKit/JetpackRestoreTypes.swift new file mode 100644 index 000000000000..2ade0637263d --- /dev/null +++ b/Modules/Sources/WordPressKit/JetpackRestoreTypes.swift @@ -0,0 +1,35 @@ +import Foundation + +public struct JetpackRestoreTypes { + public var themes: Bool + public var plugins: Bool + public var uploads: Bool + public var sqls: Bool + public var roots: Bool + public var contents: Bool + + public init(themes: Bool = true, + plugins: Bool = true, + uploads: Bool = true, + sqls: Bool = true, + roots: Bool = true, + contents: Bool = true) { + self.themes = themes + self.plugins = plugins + self.uploads = uploads + self.sqls = sqls + self.roots = roots + self.contents = contents + } + + func toDictionary() -> [String: AnyObject] { + return [ + "themes": themes as AnyObject, + "plugins": plugins as AnyObject, + "uploads": uploads as AnyObject, + "sqls": sqls as AnyObject, + "roots": roots as AnyObject, + "contents": contents as AnyObject + ] + } +} diff --git a/Modules/Sources/WordPressKit/JetpackScan.swift b/Modules/Sources/WordPressKit/JetpackScan.swift new file mode 100644 index 000000000000..8ac50911946a --- /dev/null +++ b/Modules/Sources/WordPressKit/JetpackScan.swift @@ -0,0 +1,75 @@ +import Foundation + +public struct JetpackScan: Decodable { + public enum JetpackScanState: String, Decodable, UnknownCaseRepresentable { + case idle + case scanning + case unavailable + case provisioning + + // Internal states that don't come from the server + + // The scan will be in this state when its in the process of fixing any fixable threats + case fixingThreats + + case unknown + static let unknownCase: Self = .unknown + } + + /// Whether the scan feature is available or not + public var isEnabled: Bool { + return (state != .unavailable) && (state != .unknown) + } + + /// The state of the current scan + public var state: JetpackScanState + + /// If a scan is in an unavailable state, this will return the reason + public var reason: String? + + /// If there is a scan in progress, this will return its status + public var current: JetpackScanStatus? + + /// Scan Status for the most recent scan + /// This will be nil if there is currently a scan taking place + public var mostRecent: JetpackScanStatus? + + /// An array of the current threats + /// During a scan this will return the previous scans threats + public var threats: [JetpackScanThreat]? + + /// A limited representation of the users credientals for each role + public var credentials: [JetpackScanCredentials]? + + /// Internal var that doesn't come from the server + /// An array of the threats being fixed current + public var threatFixStatus: [JetpackThreatFixStatus]? + + // MARK: - Private: Decodable + private enum CodingKeys: String, CodingKey { + case mostRecent, state, reason, current, threats, credentials + } +} + +// MARK: - JetpackScanStatus +public struct JetpackScanStatus: Decodable { + public var isInitial: Bool + + /// The date the scan started + public var startDate: Date? + + /// The progress of the scan from 0 - 100 + public var progress: Int + + /// How long the scan took / is taking + public var duration: TimeInterval? + + /// If there was an error finishing the scan + /// This will only be available for past scans + public var didFail: Bool? + + private enum CodingKeys: String, CodingKey { + case startDate = "timestamp", didFail = "error" + case duration, progress, isInitial + } +} diff --git a/Modules/Sources/WordPressKit/JetpackScanHistory.swift b/Modules/Sources/WordPressKit/JetpackScanHistory.swift new file mode 100644 index 000000000000..3074ad82e9dc --- /dev/null +++ b/Modules/Sources/WordPressKit/JetpackScanHistory.swift @@ -0,0 +1,40 @@ +import Foundation + +public struct JetpackScanHistory: Decodable { + public let threats: [JetpackScanThreat] + public let lifetimeStats: JetpackScanHistoryStats +} + +public struct JetpackScanHistoryStats: Decodable { + public let scans: Int? + public let threatsFound: Int? + public let threatsResolved: Int? + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + scans = Self.decode(in: container, forKey: .scans) + threatsFound = Self.decode(in: container, forKey: .threatsFound) + threatsResolved = Self.decode(in: container, forKey: .threatsResolved) + } + + /// Special handling of the decoding since it could be a string or an int + private static func decode(in container: KeyedDecodingContainer, forKey key: CodingKeys) -> Int? { + var intVal: Int? + if let stringVal = try? container.decode(String.self, forKey: key) { + intVal = Int(stringVal) + } else if let val = try? container.decode(Int.self, forKey: key) { + intVal = val + } + + guard let value = intVal else { + return nil + } + + return value < 0 ? nil : value + } + + private enum CodingKeys: String, CodingKey { + case scans, threatsFound, threatsResolved + } +} diff --git a/Modules/Sources/WordPressKit/JetpackScanServiceRemote.swift b/Modules/Sources/WordPressKit/JetpackScanServiceRemote.swift new file mode 100644 index 000000000000..928e02bc68a1 --- /dev/null +++ b/Modules/Sources/WordPressKit/JetpackScanServiceRemote.swift @@ -0,0 +1,161 @@ +import Foundation +import WordPressKitObjC + +public class JetpackScanServiceRemote: ServiceRemoteWordPressComREST { + // MARK: - Scanning + public func getScanAvailableForSite(_ siteID: Int, success: @escaping(Bool) -> Void, failure: @escaping(Error) -> Void) { + getScanForSite(siteID, success: { (scan) in + success(scan.isEnabled) + }, failure: failure) + } + + public func getCurrentScanStatusForSite(_ siteID: Int, success: @escaping(JetpackScanStatus?) -> Void, failure: @escaping(Error) -> Void) { + getScanForSite(siteID, success: { scan in + success(scan.current) + }, failure: failure) + } + + /// Starts a scan for a site + public func startScanForSite(_ siteID: Int, success: @escaping(Bool) -> Void, failure: @escaping(Error) -> Void) { + let path = self.scanPath(for: siteID, with: "enqueue") + + wordPressComRESTAPI.post(path, parameters: nil, success: { (response, _) in + guard let responseDict = response as? [String: Any], + let responseValue = responseDict["success"] as? Bool else { + success(false) + return + } + + success(responseValue) + }, failure: { (error, _) in + failure(error) + }) + } + + /// Gets the main scan object + public func getScanForSite(_ siteID: Int, success: @escaping(JetpackScan) -> Void, failure: @escaping(Error) -> Void) { + let path = self.scanPath(for: siteID) + + wordPressComRESTAPI.get(path, parameters: nil, success: { (response, _) in + do { + let decoder = JSONDecoder.apiDecoder + let data = try JSONSerialization.data(withJSONObject: response, options: []) + let envelope = try decoder.decode(JetpackScan.self, from: data) + + success(envelope) + } catch { + failure(error) + } + + }, failure: { (error, _) in + failure(error) + }) + } + + // MARK: - Threats + public enum ThreatError: Swift.Error { + case invalidResponse + } + + public func getThreatsForSite(_ siteID: Int, success: @escaping([JetpackScanThreat]?) -> Void, failure: @escaping(Error) -> Void) { + getScanForSite(siteID, success: { scan in + success(scan.threats) + }, failure: failure) + } + + /// Begins the fix process for multiple threats + public func fixThreats(_ threats: [JetpackScanThreat], siteID: Int, success: @escaping(JetpackThreatFixResponse) -> Void, failure: @escaping(Error) -> Void) { + let path = self.path(forEndpoint: "sites/\(siteID)/alerts/fix", withVersion: ._2_0) + let parameters = ["threat_ids": threats.map { $0.id as AnyObject }] as [String: AnyObject] + + wordPressComRESTAPI.post(path, parameters: parameters, success: { (response, _) in + do { + let decoder = JSONDecoder.apiDecoder + let data = try JSONSerialization.data(withJSONObject: response, options: []) + let envelope = try decoder.decode(JetpackThreatFixResponse.self, from: data) + + success(envelope) + } catch { + failure(error) + } + }, failure: { (error, _) in + failure(error) + }) + } + + /// Begins the fix process for a single threat + public func fixThreat(_ threat: JetpackScanThreat, siteID: Int, success: @escaping(JetpackThreatFixStatus) -> Void, failure: @escaping(Error) -> Void) { + fixThreats([threat], siteID: siteID, success: { response in + guard let status = response.threats.first else { + failure(ThreatError.invalidResponse) + return + } + + success(status) + }, failure: { error in + failure(error) + }) + } + + /// Begins the ignore process for a single threat + public func ignoreThreat(_ threat: JetpackScanThreat, siteID: Int, success: @escaping () -> Void, failure: @escaping(Error) -> Void) { + let path = self.path(forEndpoint: "sites/\(siteID)/alerts/\(threat.id)", withVersion: ._2_0) + let parameters = ["ignore": true] as [String: AnyObject] + + wordPressComRESTAPI.post(path, parameters: parameters, success: { (_, _) in + success() + }, failure: { (error, _) in + failure(error) + }) + } + + /// Returns the fix status for multiple threats + public func getFixStatusForThreats(_ threats: [JetpackScanThreat], siteID: Int, success: @escaping(JetpackThreatFixResponse) -> Void, failure: @escaping(Error) -> Void) { + let path = self.path(forEndpoint: "sites/\(siteID)/alerts/fix", withVersion: ._2_0) + let parameters = ["threat_ids": threats.map { $0.id as AnyObject }] as [String: AnyObject] + + wordPressComRESTAPI.get(path, parameters: parameters, success: { (response, _) in + do { + let decoder = JSONDecoder.apiDecoder + let data = try JSONSerialization.data(withJSONObject: response, options: []) + let envelope = try decoder.decode(JetpackThreatFixResponse.self, from: data) + + success(envelope) + } catch { + failure(error) + } + }, failure: { (error, _) in + failure(error) + }) + } + + // MARK: - History + public func getHistoryForSite(_ siteID: Int, success: @escaping(JetpackScanHistory) -> Void, failure: @escaping(Error) -> Void) { + let path = scanPath(for: siteID, with: "history") + + wordPressComRESTAPI.get(path, parameters: nil, success: { (response, _) in + do { + let decoder = JSONDecoder.apiDecoder + let data = try JSONSerialization.data(withJSONObject: response, options: []) + let envelope = try decoder.decode(JetpackScanHistory.self, from: data) + + success(envelope) + } catch { + failure(error) + } + }, failure: { (error, _) in + failure(error) + }) + } + + // MARK: - Private + private func scanPath(for siteID: Int, with path: String? = nil) -> String { + var endpoint = "sites/\(siteID)/scan/" + + if let path { + endpoint = endpoint.appending(path) + } + + return self.path(forEndpoint: endpoint, withVersion: ._2_0) + } +} diff --git a/Modules/Sources/WordPressKit/JetpackScanThreat.swift b/Modules/Sources/WordPressKit/JetpackScanThreat.swift new file mode 100644 index 000000000000..d5242b0cb127 --- /dev/null +++ b/Modules/Sources/WordPressKit/JetpackScanThreat.swift @@ -0,0 +1,256 @@ +import Foundation + +public struct JetpackScanThreat: Decodable { + public enum ThreatStatus: String, Decodable, UnknownCaseRepresentable { + case fixed + case ignored + case current + + // Internal states + case fixing + + case unknown + static let unknownCase: Self = .unknown + } + + public enum ThreatType: String, UnknownCaseRepresentable { + case core + case file + case plugin + case theme + case database + + case unknown + static let unknownCase: Self = .unknown + + init(threat: JetpackScanThreat) { + // Logic used from https://github.com/Automattic/wp-calypso/blob/5a6b257ad97b361fa6f6a6e496cbfc0ef952b921/client/components/jetpack/threat-item/utils.ts#L11 + if threat.diff != nil { + self = .core + } else if threat.context != nil { + self = .file + } else if let ext = threat.extension { + self = ThreatType(rawValue: ext.type.rawValue) + } else if threat.rows != nil || threat.signature.contains(Constants.databaseSignature) { + self = .database + } else { + self = .unknown + } + } + + private struct Constants { + static let databaseSignature = "Suspicious.Links" + } + } + + /// The ID of the threat + public let id: Int + + /// The name of the threat signature + public let signature: String + + /// The description of the threat signature + public let description: String + + /// The date the threat was first detected + public let firstDetected: Date + + /// Whether the threat can be automatically fixed + public let fixable: JetpackScanThreatFixer? + + /// The filename + public let fileName: String? + + /// The status of the threat (fixed, ignored, current) + public var status: ThreatStatus? + + /// The date the threat was fixed on + public let fixedOn: Date? + + /// More information if the threat is a extension type (plugin or theme) + public let `extension`: JetpackThreatExtension? + + /// The type of threat this is + public private(set) var type: ThreatType = .unknown + + /// If this is a file based threat this will provide code context to be displayed + /// Context example: + /// 3: start test + /// 4: VIRUS_SIG + /// 5: end test + /// marks: 4: (0, 9) + public let context: JetpackThreatContext? + + // Core modification threats will contain a git diff string + public let diff: String? + + // Database threats will contain row information + public let rows: [String: Any]? + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + id = try container.decode(Int.self, forKey: .id) + signature = try container.decode(String.self, forKey: .signature) + fileName = try container.decodeIfPresent(String.self, forKey: .fileName) + description = try container.decode(String.self, forKey: .description) + firstDetected = try container.decode(Date.self, forKey: .firstDetected) + fixedOn = try container.decodeIfPresent(Date.self, forKey: .fixedOn) + fixable = try? container.decodeIfPresent(JetpackScanThreatFixer.self, forKey: .fixable) ?? nil + `extension` = try container.decodeIfPresent(JetpackThreatExtension.self, forKey: .extension) + diff = try container.decodeIfPresent(String.self, forKey: .diff) + rows = try container.decodeIfPresent([String: Any].self, forKey: .rows) + status = try container.decode(ThreatStatus.self, forKey: .status) + + // Context obj can either be: + // - not present + // - a dictionary + // - an empty string + // we can not just set to nil because the threat type logic needs to know if the + // context attr was present or not + if let contextDict = try? container.decodeIfPresent([String: Any].self, forKey: .context) { + context = JetpackThreatContext(with: contextDict) + } else if (try container.decodeIfPresent(String.self, forKey: .context)) != nil { + context = JetpackThreatContext.emptyObject() + } else { + context = nil + } + + // Calculate the type of threat last + type = ThreatType(threat: self) + } + + private enum CodingKeys: String, CodingKey { + case fileName = "filename" + case firstDetected, fixedOn + case id, signature, description, fixable + case `extension`, diff, context, rows, status + } +} + +/// An object that describes how a threat can be fixed +public struct JetpackScanThreatFixer: Decodable { + public enum ThreatFixType: String, Decodable, UnknownCaseRepresentable { + case replace + case delete + case update + case edit + case rollback + + case unknown + static let unknownCase: Self = .unknown + } + + /// The suggested threat fix type + public let type: ThreatFixType + + /// The file path of the file to be fixed + public var file: String? + + /// The target version to fix to + public var target: String? + + private enum CodingKeys: String, CodingKey { + case type = "fixer", file, target + } +} + +/// Represents plugin or theme additional metadata +public struct JetpackThreatExtension: Decodable { + public enum JetpackThreatExtensionType: String, Decodable, UnknownCaseRepresentable { + case plugin + case theme + + case unknown + static let unknownCase: Self = .unknown + } + + public let slug: String + public let name: String + public let type: JetpackThreatExtensionType + public let isPremium: Bool + public let version: String +} + +public struct JetpackThreatContext { + public struct ThreatContextLine { + public var lineNumber: Int + public var contents: String + public var highlights: [NSRange]? + } + + public let lines: [ThreatContextLine] + + public static func emptyObject() -> JetpackThreatContext { + return JetpackThreatContext(with: []) + } + + public init(with lines: [ThreatContextLine]) { + self.lines = lines + } + + public init?(with dict: [String: Any]) { + guard let marksDict = dict["marks"] as? [String: Any] else { + return nil + } + + var lines: [ThreatContextLine] = [] + + // Parse the "lines" which are represented as the keys of the dict + // "3", "4", "5" + for key in dict.keys { + // Since we've already pulled the marks out above, ignore it here + if key == "marks" { + continue + } + + // Validate the incoming object to make sure it contains an integer line, and + // the string contents + guard let lineNum = Int(key), let contents = dict[key] as? String else { + continue + } + + let highlights: [NSRange]? = Self.highlights(with: marksDict, for: key) + + let context = ThreatContextLine(lineNumber: lineNum, + contents: contents, + highlights: highlights) + + lines.append(context) + } + + // Since the dictionary keys are unsorted, resort by line number + self.lines = lines.sorted { $0.lineNumber < $1.lineNumber } + } + + /// Parses the marks dictionary and converts them to an array of NSRange's + private static func highlights(with dict: [String: Any], for key: String) -> [NSRange]? { + guard let marks = dict[key] as? [[Double]] else { + return nil + } + + var highlights: [NSRange] = [] + + for rangeArray in marks { + if let range = Self.range(with: rangeArray) { + highlights.append(range) + } + } + + return (highlights.count > 0) ? highlights : nil + } + + /// Generates an NSRange from an array + /// - Parameter array: An array that contains 2 numbers [start, length] + /// - Returns: Nil if the array fails validation, or an NSRange + private static func range(with array: [Double]) -> NSRange? { + guard array.count == 2, + let location = array.first, + let length = array.last + else { + return nil + } + + return NSRange(location: Int(location), length: Int(length - location)) + } +} diff --git a/Modules/Sources/WordPressKit/JetpackServiceRemote.swift b/Modules/Sources/WordPressKit/JetpackServiceRemote.swift new file mode 100644 index 000000000000..895b5bc8c02f --- /dev/null +++ b/Modules/Sources/WordPressKit/JetpackServiceRemote.swift @@ -0,0 +1,111 @@ +import Foundation +import WordPressKitObjC + +public struct JetpackInstallError: LocalizedError, Equatable { + public enum ErrorType: String { + case invalidCredentials = "INVALID_CREDENTIALS" + case forbidden = "FORBIDDEN" + case installFailure = "INSTALL_FAILURE" + case installResponseError = "INSTALL_RESPONSE_ERROR" + case loginFailure = "LOGIN_FAILURE" + case siteIsJetpack = "SITE_IS_JETPACK" + case activationOnInstallFailure = "ACTIVATION_ON_INSTALL_FAILURE" + case activationResponseError = "ACTIVATION_RESPONSE_ERROR" + case activationFailure = "ACTIVATION_FAILURE" + case unknown + + init(error key: String) { + self = ErrorType(rawValue: key) ?? .unknown + } + } + + public var title: String? + public var code: Int + public var type: ErrorType + + public static var unknown: JetpackInstallError { + return JetpackInstallError(type: .unknown) + } + + public init(title: String? = nil, code: Int = 0, key: String? = nil) { + self.init(title: title, code: code, type: ErrorType(error: key ?? "")) + } + + public init(title: String? = nil, code: Int = 0, type: ErrorType = .unknown) { + self.title = title + self.code = code + self.type = type + } +} + +public class JetpackServiceRemote: ServiceRemoteWordPressComREST { + public enum ResponseError: Error { + case decodingFailed + } + + public func checkSiteHasJetpack(_ url: URL, + success: @escaping (Bool) -> Void, + failure: @escaping (Error?) -> Void) { + let path = self.path(forEndpoint: "connect/site-info", withVersion: ._1_0) + let parameters = ["url": url.absoluteString as AnyObject] + wordPressComRESTAPI.get(path, + parameters: parameters, + success: { [weak self] response, _ in + do { + let hasJetpack = try self?.hasJetpackMapping(object: response) + success(hasJetpack ?? false) + } catch { + failure(error) + } + }) { error, _ in + failure(error) + } + } + + public func installJetpack(url: String, + username: String, + password: String, + completion: @escaping (Bool, JetpackInstallError?) -> Void) { + guard let escapedURL = url.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed) else { + completion(false, .unknown) + return + } + let path = String(format: "jetpack-install/%@/", escapedURL) + let requestUrl = self.path(forEndpoint: path, withVersion: ._1_0) + let parameters = ["user": username, + "password": password] + + wordPressComRESTAPI.post(requestUrl, + parameters: parameters as [String: AnyObject], + success: { response, _ in + if let response = response as? [String: Bool], + let success = response[Constants.status] { + completion(success, nil) + } else { + completion(false, JetpackInstallError(type: .installResponseError)) + } + }) { error, _ in + let error = error as NSError + let key = error.userInfo[WordPressComRestApi.ErrorKeyErrorCode] as? String + let jetpackError = JetpackInstallError(title: error.localizedDescription, + code: error.code, + key: key) + completion(false, jetpackError) + } + } + + private enum Constants { + static let hasJetpack = "hasJetpack" + static let status = "status" + } +} + +private extension JetpackServiceRemote { + func hasJetpackMapping(object: Any) throws -> Bool { + guard let response = object as? [String: AnyObject], + let hasJetpack = response[Constants.hasJetpack] as? NSNumber else { + throw ResponseError.decodingFailed + } + return hasJetpack.boolValue + } +} diff --git a/Modules/Sources/WordPressKit/JetpackSocialServiceRemote.swift b/Modules/Sources/WordPressKit/JetpackSocialServiceRemote.swift new file mode 100644 index 000000000000..7e732f3ce53b --- /dev/null +++ b/Modules/Sources/WordPressKit/JetpackSocialServiceRemote.swift @@ -0,0 +1,35 @@ +import Foundation +import WordPressKitObjC + +/// Encapsulates remote service logic related to Jetpack Social. +public class JetpackSocialServiceRemote: ServiceRemoteWordPressComREST { + + /// Retrieves the Publicize information for the given site. + /// + /// Note: Sites with disabled share limits will return success with nil value. + /// + /// - Parameters: + /// - siteID: The target site's dotcom ID. + /// - completion: Closure to be called once the request completes. + public func fetchPublicizeInfo(for siteID: Int, + completion: @escaping (Result) -> Void) { + let path = path(forEndpoint: "sites/\(siteID)/jetpack-social", withVersion: ._2_0) + Task { @MainActor in + await self.wordPressComRestApi + .perform( + .get, + URLString: path, + jsonDecoder: .apiDecoder, + type: RemotePublicizeInfo.self + ) + .map { $0.body } + .flatMapError { original -> Result in + if case let .endpointError(endpointError) = original, endpointError.response?.statusCode == 200, endpointError.code == .responseSerializationFailed { + return .success(nil) + } + return .failure(original.asNSError()) + } + .execute(completion) + } + } +} diff --git a/Modules/Sources/WordPressKit/JetpackThreatFixStatus.swift b/Modules/Sources/WordPressKit/JetpackThreatFixStatus.swift new file mode 100644 index 000000000000..e3d3877283d9 --- /dev/null +++ b/Modules/Sources/WordPressKit/JetpackThreatFixStatus.swift @@ -0,0 +1,79 @@ +import Foundation + +public struct JetpackThreatFixResponse: Decodable { + public let success: Bool + public let threats: [JetpackThreatFixStatus] + + public let isFixingThreats: Bool + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + success = try container.decode(Bool.self, forKey: .success) + + let statusDict = try container.decode([String: [String: String]].self, forKey: .threats) + var statusArray: [JetpackThreatFixStatus] = [] + + for (threatId, status) in statusDict { + guard let id = Int(threatId), let statusValue = status["status"] else { + throw ResponseError.decodingFailure + } + + let fixStatus = JetpackThreatFixStatus(with: id, status: statusValue) + statusArray.append(fixStatus) + } + + isFixingThreats = statusArray.filter { $0.status == .inProgress }.count > 0 + threats = statusArray + } + + /// Returns true the fixing status is complete, and all threats are no longer in progress + public var finished: Bool { + return inProgress.count <= 0 + } + + /// Returns all the fixed threats + public var fixed: [JetpackThreatFixStatus] { + return threats.filter { $0.status == .fixed } + } + + /// Returns all the in progress threats + public var inProgress: [JetpackThreatFixStatus] { + return threats.filter { $0.status == .inProgress } + } + + private enum CodingKeys: String, CodingKey { + case success = "ok", threats + } + + enum ResponseError: Error { + case decodingFailure + } +} + +public struct JetpackThreatFixStatus { + public enum ThreatFixStatus: String, Decodable, UnknownCaseRepresentable { + case notStarted = "not_started" + case inProgress = "in_progress" + case fixed + + case unknown + static let unknownCase: Self = .unknown + } + + public let threatId: Int + public let status: ThreatFixStatus + + public var threat: JetpackScanThreat? + + public init(with threatId: Int, status: String) { + self.threatId = threatId + self.status = ThreatFixStatus(rawValue: status) + } + + public init(with threat: JetpackScanThreat, status: ThreatFixStatus = .inProgress) { + self.threat = threat + self.threatId = threat.id + self.status = status + } +} diff --git a/Modules/Sources/WordPressKit/KeyringConnection.swift b/Modules/Sources/WordPressKit/KeyringConnection.swift new file mode 100644 index 000000000000..22656b33fdb4 --- /dev/null +++ b/Modules/Sources/WordPressKit/KeyringConnection.swift @@ -0,0 +1,23 @@ +import Foundation + +/// KeyringConnection represents a keyring connected to a particular external service. +/// We only rarely need keyring data and we don't really need to persist it. For these +/// reasons KeyringConnection is treated like a model, even though it is not an NSManagedObject, +/// but also treated like it is a Remote Object. +/// +open class KeyringConnection: NSObject { + @objc open var additionalExternalUsers = [KeyringConnectionExternalUser]() + @objc open var dateIssued = Date() + @objc open var dateExpires: Date? + @objc open var externalID = "" // Some services uses strings for their IDs + @objc open var externalName = "" + @objc open var externalDisplay = "" + @objc open var externalProfilePicture = "" + @objc open var label = "" + @objc open var keyringID: NSNumber = 0 + @objc open var refreshURL = "" + @objc open var service = "" + @objc open var status = "" + @objc open var type = "" + @objc open var userID: NSNumber = 0 +} diff --git a/Modules/Sources/WordPressKit/KeyringConnectionExternalUser.swift b/Modules/Sources/WordPressKit/KeyringConnectionExternalUser.swift new file mode 100644 index 000000000000..72a10467acbf --- /dev/null +++ b/Modules/Sources/WordPressKit/KeyringConnectionExternalUser.swift @@ -0,0 +1,11 @@ +import Foundation + +/// KeyringConnectionExternalUser represents an additional user account on the +/// external service that could be used other than the default account. +/// +open class KeyringConnectionExternalUser: NSObject { + @objc open var externalID = "" + @objc open var externalName = "" + @objc open var externalCategory = "" + @objc open var externalProfilePicture = "" +} diff --git a/Modules/Sources/WordPressKit/MultipartForm.swift b/Modules/Sources/WordPressKit/MultipartForm.swift new file mode 100644 index 000000000000..b5bf42baea01 --- /dev/null +++ b/Modules/Sources/WordPressKit/MultipartForm.swift @@ -0,0 +1,159 @@ +import Foundation + +enum MultipartFormError: Swift.Error { + case inaccessbileFile(path: String) + case impossible +} + +struct MultipartFormField { + let name: String + let filename: String? + let mimeType: String? + let bytes: UInt64 + + fileprivate let inputStream: InputStream + + init(text: String, name: String, filename: String? = nil, mimeType: String? = nil) { + self.init(data: text.data(using: .utf8)!, name: name, filename: filename, mimeType: mimeType) + } + + init(data: Data, name: String, filename: String? = nil, mimeType: String? = nil) { + self.inputStream = InputStream(data: data) + self.name = name + self.filename = filename + self.bytes = UInt64(data.count) + self.mimeType = mimeType + } + + init(fileAtPath path: String, name: String, filename: String? = nil, mimeType: String? = nil) throws { + guard let inputStream = InputStream(fileAtPath: path), + let attrs = try? FileManager.default.attributesOfItem(atPath: path), + let bytes = (attrs[FileAttributeKey.size] as? NSNumber)?.uint64Value else { + throw MultipartFormError.inaccessbileFile(path: path) + } + self.inputStream = inputStream + self.name = name + self.filename = filename ?? path.split(separator: "/").last.flatMap({ String($0) }) + self.bytes = bytes + self.mimeType = mimeType + } +} + +extension Array where Element == MultipartFormField { + private func multipartFormDestination(forceWriteToFile: Bool) throws -> (outputStream: OutputStream, tempFilePath: String?) { + let dest: OutputStream + let tempFilePath: String? + + // Build the form data in memory if the content is estimated to be less than 10 MB. Otherwise, use a temporary file. + let thresholdBytesForUsingTmpFile = 10_000_000 + let estimatedFormDataBytes = reduce(0) { $0 + $1.bytes } + if forceWriteToFile || estimatedFormDataBytes > thresholdBytesForUsingTmpFile { + let tempFile = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString).path + guard let stream = OutputStream(toFileAtPath: tempFile, append: false) else { + throw MultipartFormError.inaccessbileFile(path: tempFile) + } + dest = stream + tempFilePath = tempFile + } else { + dest = OutputStream.toMemory() + tempFilePath = nil + } + + return (dest, tempFilePath) + } + + func multipartFormDataStream(boundary: String, forceWriteToFile: Bool = false) throws -> Either { + guard !isEmpty else { + return .left(Data()) + } + + let (dest, tempFilePath) = try multipartFormDestination(forceWriteToFile: forceWriteToFile) + + // Build the form content + do { + dest.open() + defer { dest.close() } + + writeMultipartFormData(destination: dest, boundary: boundary) + } + + // Return the result as `InputStream` + if let tempFilePath { + return .right(URL(fileURLWithPath: tempFilePath)) + } + + if let data = dest.property(forKey: .dataWrittenToMemoryStreamKey) as? Data { + return .left(data) + } + + throw MultipartFormError.impossible + } + + private func writeMultipartFormData(destination dest: OutputStream, boundary: String) { + for field in self { + dest.writeMultipartForm(boundary: boundary, isEnd: false) + + // Write headers + var disposition = ["form-data", "name=\"\(field.name)\""] + if let filename = field.filename { + disposition += ["filename=\"\(filename)\""] + } + dest.writeMultipartFormHeader(name: "Content-Disposition", value: disposition.joined(separator: "; ")) + + if let mimeType = field.mimeType { + dest.writeMultipartFormHeader(name: "Content-Type", value: mimeType) + } + + // Write a linebreak between header and content + dest.writeMultipartFormLineBreak() + + // Write content + field.inputStream.open() + defer { + field.inputStream.close() + } + let maxLength = 1024 + var buffer = [UInt8](repeating: 0, count: maxLength) + while field.inputStream.hasBytesAvailable { + let bytes = field.inputStream.read(&buffer, maxLength: maxLength) + dest.write(data: Data(bytesNoCopy: &buffer, count: bytes, deallocator: .none)) + } + + dest.writeMultipartFormLineBreak() + } + + dest.writeMultipartForm(boundary: boundary, isEnd: true) + } +} + +private let multipartFormDataLineBreak = "\r\n" +private extension OutputStream { + func write(data: Data) { + let count = data.count + guard count > 0 else { return } + + _ = data.withUnsafeBytes { (ptr: UnsafeRawBufferPointer) in + write(ptr.bindMemory(to: Int8.self).baseAddress!, maxLength: count) + } + } + + func writeMultipartForm(lineContent: String) { + write(data: "\(lineContent)\(multipartFormDataLineBreak)".data(using: .utf8)!) + } + + func writeMultipartFormLineBreak() { + write(data: multipartFormDataLineBreak.data(using: .utf8)!) + } + + func writeMultipartFormHeader(name: String, value: String) { + writeMultipartForm(lineContent: "\(name): \(value)") + } + + func writeMultipartForm(boundary: String, isEnd: Bool) { + if isEnd { + writeMultipartForm(lineContent: "--\(boundary)--") + } else { + writeMultipartForm(lineContent: "--\(boundary)") + } + } +} diff --git a/Modules/Sources/WordPressKit/NSAttributedString+extensions.swift b/Modules/Sources/WordPressKit/NSAttributedString+extensions.swift new file mode 100644 index 000000000000..a24db85e50a3 --- /dev/null +++ b/Modules/Sources/WordPressKit/NSAttributedString+extensions.swift @@ -0,0 +1,32 @@ +import Foundation + +extension NSAttributedString { + /// This helper method returns a new NSAttributedString instance, with all of the the leading / trailing newLines + /// characters removed. + /// + @objc public func trimNewlines() -> NSAttributedString { + guard let trimmed = mutableCopy() as? NSMutableAttributedString else { + return self + } + + let characterSet = CharacterSet.newlines + + // Trim: Leading + var range = (trimmed.string as NSString).rangeOfCharacter(from: characterSet) + + while range.length != 0 && range.location == 0 { + trimmed.replaceCharacters(in: range, with: String()) + range = (trimmed.string as NSString).rangeOfCharacter(from: characterSet) + } + + // Trim Trailing + range = (trimmed.string as NSString).rangeOfCharacter(from: characterSet, options: .backwards) + + while range.length != 0 && NSMaxRange(range) == trimmed.length { + trimmed.replaceCharacters(in: range, with: String()) + range = (trimmed.string as NSString).rangeOfCharacter(from: characterSet, options: .backwards) + } + + return trimmed + } +} diff --git a/Modules/Sources/WordPressKit/NSMutableData+Helpers.swift b/Modules/Sources/WordPressKit/NSMutableData+Helpers.swift new file mode 100644 index 000000000000..6f5966eee7e2 --- /dev/null +++ b/Modules/Sources/WordPressKit/NSMutableData+Helpers.swift @@ -0,0 +1,16 @@ +import Foundation + +/// Encapsulates all of the NSMutableData Helper Methods. +/// +extension NSMutableData { + + /// Encodes a raw String into UTF8, and appends it to the current instance. + /// + /// - Parameter string: The raw String to be UTF8-Encoded, and appended + /// + @objc func appendString(_ string: String) { + if let data = string.data(using: String.Encoding.utf8) { + append(data) + } + } +} diff --git a/Modules/Sources/WordPressKit/NSMutableParagraphStyle+extensions.swift b/Modules/Sources/WordPressKit/NSMutableParagraphStyle+extensions.swift new file mode 100644 index 000000000000..831c2f741619 --- /dev/null +++ b/Modules/Sources/WordPressKit/NSMutableParagraphStyle+extensions.swift @@ -0,0 +1,15 @@ +import Foundation + +extension NSMutableParagraphStyle { + @objc convenience public init(minLineHeight: CGFloat, lineBreakMode: NSLineBreakMode, alignment: NSTextAlignment) { + self.init() + self.minimumLineHeight = minLineHeight + self.lineBreakMode = lineBreakMode + self.alignment = alignment + } + + @objc convenience public init(minLineHeight: CGFloat, maxLineHeight: CGFloat, lineBreakMode: NSLineBreakMode, alignment: NSTextAlignment) { + self.init(minLineHeight: minLineHeight, lineBreakMode: lineBreakMode, alignment: alignment) + self.maximumLineHeight = maxLineHeight + } +} diff --git a/Modules/Sources/WordPressKit/NonceRetrieval.swift b/Modules/Sources/WordPressKit/NonceRetrieval.swift new file mode 100644 index 000000000000..14e13b15d338 --- /dev/null +++ b/Modules/Sources/WordPressKit/NonceRetrieval.swift @@ -0,0 +1,95 @@ +import Foundation + +enum NonceRetrievalMethod { + case newPostScrap + case ajaxNonceRequest + + func retrieveNonce(username: String, password: Secret, loginURL: URL, adminURL: URL, using urlSession: URLSession) async -> String? { + guard let webpageThatContainsNonce = buildURL(base: adminURL) else { return nil } + + // First, make a request to the URL to grab REST API nonce. The HTTP request is very likely to pass, because + // when this method is called, user should have already authenticated and their site's cookies are already in + // the `urlSession. + if let found = await nonce(from: webpageThatContainsNonce, using: urlSession) { + return found + } + + // If the above request failed, then make a login request, which redirects to the webpage that contains + // REST API nonce. + let loginThenRedirect = HTTPRequestBuilder(url: loginURL) + .method(.post) + .body(form: [ + "log": username, + "pwd": password.secretValue, + "rememberme": "true", + "redirect_to": webpageThatContainsNonce.absoluteString + ]) + + return await nonce(from: loginThenRedirect, using: urlSession) + } + + private func buildURL(base: URL) -> URL? { + switch self { + case .newPostScrap: + return URL(string: "post-new.php", relativeTo: base) + case .ajaxNonceRequest: + return URL(string: "admin-ajax.php?action=rest-nonce", relativeTo: base) + } + } + + private func retrieveNonce(from html: String) -> String? { + switch self { + case .newPostScrap: + return scrapNonceFromNewPost(html: html) + case .ajaxNonceRequest: + return readNonceFromAjaxAction(html: html) + } + } + + private func scrapNonceFromNewPost(html: String) -> String? { + guard let regex = try? NSRegularExpression(pattern: "apiFetch.createNonceMiddleware\\(\\s*['\"](?\\w+)['\"]\\s*\\)", options: []), + let match = regex.firstMatch(in: html, options: [], range: NSRange(location: 0, length: html.count)) else { + return nil + } + let nsrange = match.range(withName: "nonce") + let nonce = Range(nsrange, in: html) + .map({ html[$0] }) + .map( String.init ) + + return nonce + } + + private func readNonceFromAjaxAction(html: String) -> String? { + guard !html.isEmpty, + html.allSatisfy({ $0.isNumber || $0.isLetter }) + else { + return nil + } + + return html + } +} + +private extension NonceRetrievalMethod { + + func nonce(from url: URL, using urlSession: URLSession) async -> String? { + await nonce(from: HTTPRequestBuilder(url: url), using: urlSession) + } + + func nonce(from builder: HTTPRequestBuilder, using urlSession: URLSession) async -> String? { + guard let request = try? builder.build() else { return nil } + + guard let (data, response) = try? await urlSession.data(for: request), + let httpResponse = response as? HTTPURLResponse + else { + return nil + } + + guard 200...299 ~= httpResponse.statusCode, let content = HTTPAPIResponse(response: httpResponse, body: data).bodyText else { + return nil + } + + return retrieveNonce(from: content) + } + +} diff --git a/Modules/Sources/WordPressKit/NotificationSettingsServiceRemote.swift b/Modules/Sources/WordPressKit/NotificationSettingsServiceRemote.swift new file mode 100644 index 000000000000..71ff15fecacb --- /dev/null +++ b/Modules/Sources/WordPressKit/NotificationSettingsServiceRemote.swift @@ -0,0 +1,131 @@ +import Foundation +import WordPressKitObjC + +/// The purpose of this class is to encapsulate all of the interaction with the Notifications REST endpoints. +/// Here we'll deal mostly with the Settings / Push Notifications API. +/// +open class NotificationSettingsServiceRemote: ServiceRemoteWordPressComREST { + /// Retrieves all of the Notification Settings + /// + /// - Parameters: + /// - deviceId: The ID of the current device. Can be nil. + /// - success: A closure to be called on success, which will receive the parsed settings entities. + /// - failure: Optional closure to be called on failure. Will receive the error that was encountered. + /// + open func getAllSettings(_ deviceId: String, success: (([RemoteNotificationSettings]) -> Void)?, failure: ((NSError?) -> Void)?) { + let path = String(format: "me/notifications/settings/?device_id=%@", deviceId) + let requestUrl = self.path(forEndpoint: path, withVersion: ._1_1) + + wordPressComRESTAPI.get(requestUrl, + parameters: nil, + success: { response, _ in + let settings = RemoteNotificationSettings.fromDictionary(response as? NSDictionary) + success?(settings) + }, + failure: { error, _ in + failure?(error as NSError) + }) + } + + /// Updates the specified Notification Settings + /// + /// - Parameters: + /// - settings: The complete (or partial) dictionary of settings to be updated. + /// - success: Optional closure to be called on success. + /// - failure: Optional closure to be called on failure. + /// + @objc open func updateSettings(_ settings: [String: AnyObject], success: (() -> Void)?, failure: ((NSError?) -> Void)?) { + let path = String(format: "me/notifications/settings/") + let requestUrl = self.path(forEndpoint: path, withVersion: ._1_1) + + let parameters = settings + + wordPressComRESTAPI.post(requestUrl, + parameters: parameters, + success: { _, _ in + success?() + }, + failure: { error, _ in + failure?(error as NSError) + }) + } + + /// Registers a given Apple Push Token in the WordPress.com Backend. + /// + /// - Parameters: + /// - token: The token of the device to be registered. + /// - pushNotificationAppId: The app id to be registered. + /// - success: Optional closure to be called on success. + /// - failure: Optional closure to be called on failure. + /// + @objc open func registerDeviceForPushNotifications(_ token: String, pushNotificationAppId: String, success: ((_ deviceId: String) -> Void)?, failure: ((NSError) -> Void)?) { + let endpoint = "devices/new" + let requestUrl = path(forEndpoint: endpoint, withVersion: ._1_1) + + let device = UIDevice.current + let parameters = [ + "device_token": token, + "device_family": "apple", + "app_secret_key": pushNotificationAppId, + "device_name": device.name, + "device_model": device.platform, + "os_version": device.systemVersion, + "app_version": Bundle.main.wpkit_bundleVersion(), + "device_uuid": device.identifierForVendor?.uuidString + ] + + wordPressComRESTAPI.post(requestUrl, + parameters: parameters as [String: Any], + success: { response, _ in + if let responseDict = response as? NSDictionary, + let rawDeviceId = responseDict.object(forKey: "ID") { + // Failsafe: Make sure deviceId is always a string + let deviceId = String(format: "\(rawDeviceId)") + success?(deviceId) + } else { + let innerError = Error.invalidResponse + let outerError = NSError(domain: innerError.domain, code: innerError.code, userInfo: nil) + + failure?(outerError) + } + }, + failure: { error, _ in + failure?(error as NSError) + }) + } + + /// Unregisters a given DeviceID for Push Notifications + /// + /// - Parameters: + /// - deviceId: The ID of the device to be unregistered. + /// - success: Optional closure to be called on success. + /// - failure: Optional closure to be called on failure. + /// + @objc open func unregisterDeviceForPushNotifications(_ deviceId: String, success: (() -> Void)?, failure: ((NSError) -> Void)?) { + let endpoint = String(format: "devices/%@/delete", deviceId) + let requestUrl = path(forEndpoint: endpoint, withVersion: ._1_1) + + wordPressComRESTAPI.post(requestUrl, + parameters: nil, + success: { _, _ in + success?() + }, + failure: { error, _ in + failure?(error as NSError) + }) + } + + /// Describes all of the possible errors that might be generated by this class. + /// + public enum Error: Int { + case invalidResponse = -1 + + var code: Int { + return rawValue + } + + var domain: String { + return "NotificationSettingsServiceRemote" + } + } +} diff --git a/Modules/Sources/WordPressKit/NotificationSyncServiceRemote.swift b/Modules/Sources/WordPressKit/NotificationSyncServiceRemote.swift new file mode 100644 index 000000000000..1789ed47a767 --- /dev/null +++ b/Modules/Sources/WordPressKit/NotificationSyncServiceRemote.swift @@ -0,0 +1,185 @@ +import Foundation +import WordPressKitObjC + +// MARK: - NotificationSyncServiceRemote +// +public class NotificationSyncServiceRemote: ServiceRemoteWordPressComREST { + // MARK: - Constants + // + private let defaultPageSize = 100 + + // MARK: - Errors + // + public enum SyncError: Error { + case failed + } + + public enum InputError: Int, Error { + case notificationIDsNotProvided + } + + /// Retrieves latest Notifications (OR collection of Notifications, whenever noteIds is present) + /// + /// - Parameters: + /// - pageSize: Number of hashes to retrieve. + /// - noteIds: Identifiers of notifications to retrieve. + /// - completion: callback to be executed on completion. + /// + /// + public func loadNotes(withPageSize pageSize: Int? = nil, noteIds: [String]? = nil, completion: @escaping ((Error?, [RemoteNotification]?) -> Void)) { + let fields = "id,note_hash,type,unread,body,subject,timestamp,meta" + + loadNotes(withNoteIds: noteIds, fields: fields, pageSize: pageSize) { error, notes in + completion(error, notes) + } + } + + /// Retrieves the Notification Hashes for the specified pageSize (OR collection of NoteID's, when present) + /// + /// - Parameters: + /// - pageSize: Number of hashes to retrieve. + /// - noteIds: Identifiers of notifications to retrieve. + /// - completion: callback to be executed on completion. + /// + /// - Notes: The RemoteNotification Entity will only have it's ID + Hash populated + /// + public func loadHashes(withPageSize pageSize: Int? = nil, noteIds: [String]? = nil, completion: @escaping ((Error?, [RemoteNotification]?) -> Void)) { + let fields = "id,note_hash" + + loadNotes(withNoteIds: noteIds, fields: fields, pageSize: pageSize) { error, notes in + completion(error, notes) + } + } + + /// Updates a Notification's Read Status as specified. + /// + /// - Parameters: + /// - notificationID: The NotificationID to Mark as Read. + /// - read: The new Read Status to set. + /// - completion: Closure to be executed on completion, indicating whether the OP was successful or not. + /// + @objc public func updateReadStatus(_ notificationID: String, read: Bool, completion: @escaping ((Error?) -> Void)) { + updateReadStatusForNotifications([notificationID], read: read, completion: completion) + } + + /// Updates an array of Notifications' Read Status as specified. + /// + /// - Parameters: + /// - notificationIDs: ID's of Notifications to Mark as Read. + /// - read: The new Read Status to set. + /// - completion: Closure to be executed on completion, indicating whether the OP was successful or not. + /// + @objc public func updateReadStatusForNotifications(_ notificationIDs: [String], read: Bool, completion: @escaping ((Error?) -> Void)) { + guard !notificationIDs.isEmpty else { + completion(InputError.notificationIDsNotProvided) + return + } + + let path = "notifications/read" + let requestUrl = self.path(forEndpoint: path, withVersion: ._1_1) + + // Note: Isn't the API wonderful? + let value = read ? 9999 : -9999 + + var notifications: [String: Int] = [:] + + for notificationID in notificationIDs { + notifications[notificationID] = value + } + + let parameters = ["counts": notifications] + + wordPressComRESTAPI.post(requestUrl, parameters: parameters as [String: AnyObject]?, success: { (response, _) in + let error = self.errorFromResponse(response) + completion(error) + + }, failure: { (error, _) in + completion(error) + }) + } + + /// Updates the Last Seen Notification's Timestamp. + /// + /// - Parameters: + /// - timestamp: Timestamp of the last seen notification. + /// - completion: Closure to be executed on completion, indicating whether the OP was successful or not. + /// + @objc public func updateLastSeen(_ timestamp: String, completion: @escaping ((Error?) -> Void)) { + let path = "notifications/seen" + let requestUrl = self.path(forEndpoint: path, withVersion: ._1_1) + + let parameters = [ + "time": timestamp + ] + + wordPressComRESTAPI.post(requestUrl, parameters: parameters as [String: AnyObject]?, success: { (response, _) in + let error = self.errorFromResponse(response) + completion(error) + + }, failure: { (error, _) in + completion(error) + }) + } +} + +// MARK: - Private Methods +// +private extension NotificationSyncServiceRemote { + /// Attempts to parse the `success` field of a given response. When it's missing, or it's false, + /// this method will return SyncError.failed. + /// + /// - Parameter response: JSON entity , as retrieved from the backend. + /// + /// - Returns: SyncError.failed whenever the success field is either missing, or set to false. + /// + func errorFromResponse(_ response: Any) -> Error? { + let document = response as? [String: AnyObject] + let success = document?["success"] as? Bool + guard success != true else { + return nil + } + + return SyncError.failed + } + + /// Retrieves the Notification for the specified pageSize (OR collection of NoteID's, when present). + /// Note that only the specified fields will be retrieved. + /// + /// - Parameters: + /// - noteIds: Identifier for the notifications that should be loaded. + /// - fields: List of comma separated fields, to be loaded. + /// - pageSize: Number of notifications to load. + /// - completion: Callback to be executed on completion. + /// + func loadNotes(withNoteIds noteIds: [String]? = nil, fields: String? = nil, pageSize: Int?, completion: @escaping ((Error?, [RemoteNotification]?) -> Void)) { + let path = "notifications/" + let requestUrl = self.path(forEndpoint: path, withVersion: ._1_1) + + var parameters: [String: AnyObject] = [ + "number": pageSize as AnyObject? ?? defaultPageSize as AnyObject + ] + + if let notificationIds = noteIds { + parameters["ids"] = (notificationIds as NSArray).componentsJoined(by: ",") as AnyObject? + } + + if let fields { + parameters["fields"] = fields as AnyObject? + } + + wordPressComRESTAPI.get(requestUrl, parameters: parameters, success: { response, _ in + let document = response as? [String: AnyObject] + let notes = document?["notes"] as? [[String: AnyObject]] + let parsed = notes?.compactMap { RemoteNotification(document: $0) } + + if let parsed { + completion(nil, parsed) + } else { + completion(SyncError.failed, nil) + } + + }, failure: { error, _ in + completion(error, nil) + }) + } +} diff --git a/Modules/Sources/WordPressKit/PageLayoutServiceRemote.swift b/Modules/Sources/WordPressKit/PageLayoutServiceRemote.swift new file mode 100644 index 000000000000..10636fbc6756 --- /dev/null +++ b/Modules/Sources/WordPressKit/PageLayoutServiceRemote.swift @@ -0,0 +1,32 @@ +import Foundation + +public class PageLayoutServiceRemote { + + public typealias CompletionHandler = (Swift.Result) -> Void + public static func fetchLayouts(_ api: WordPressComRestApi, forBlogID blogID: Int?, withParameters parameters: [String: AnyObject]?, completion: @escaping CompletionHandler) { + let urlPath: String + if let blogID { + urlPath = "/wpcom/v2/sites/\(blogID)/block-layouts" + } else { + urlPath = "/wpcom/v2/common-block-layouts" + } + + api.GET(urlPath, parameters: parameters, success: { (responseObject, _) in + guard let result = parseLayouts(fromResponse: responseObject) else { + let error = NSError(domain: "PageLayoutService", code: 0, userInfo: [NSDebugDescriptionErrorKey: "Unable to parse response"]) + completion(.failure(error)) + return + } + completion(.success(result)) + }, failure: { (error, _) in + completion(.failure(error)) + }) + } + + private static func parseLayouts(fromResponse response: Any) -> RemotePageLayouts? { + guard let data = try? JSONSerialization.data(withJSONObject: response) else { + return nil + } + return try? JSONDecoder().decode(RemotePageLayouts.self, from: data) + } +} diff --git a/Modules/Sources/WordPressKit/PeopleServiceRemote.swift b/Modules/Sources/WordPressKit/PeopleServiceRemote.swift new file mode 100644 index 000000000000..ef179b950fde --- /dev/null +++ b/Modules/Sources/WordPressKit/PeopleServiceRemote.swift @@ -0,0 +1,638 @@ +import Foundation +import WordPressKitObjC + +/// Encapsulates all of the People Management WordPress.com Methods +/// +public class PeopleServiceRemote: ServiceRemoteWordPressComREST { + + /// Defines the PeopleServiceRemote possible errors. + /// + public enum ResponseError: Error { + case decodingFailure + case invalidInputError + case userAlreadyHasRoleError + case unknownError + } + + /// Retrieves the collection of users associated to a given Site. + /// + /// - Parameters: + /// - siteID: The target site's ID. + /// - offset: The first N users to be skipped in the returned array. + /// - count: Number of objects to retrieve. + /// - success: Closure to be executed on success. + /// - failure: Closure to be executed on error. + /// + /// - Returns: An array of Users. + /// + public func getUsers(_ siteID: Int, + offset: Int = 0, + count: Int, + success: @escaping ((_ users: [User], _ hasMore: Bool) -> Void), + failure: @escaping ((Error) -> Void)) { + let endpoint = "sites/\(siteID)/users" + let path = self.path(forEndpoint: endpoint, withVersion: ._1_1) + let parameters: [String: AnyObject] = [ + "number": count as AnyObject, + "offset": offset as AnyObject, + "order_by": "display_name" as AnyObject, + "order": "ASC" as AnyObject, + "fields": "ID, nice_name, first_name, last_name, name, avatar_URL, roles, is_super_admin, linked_user_ID" as AnyObject + ] + + wordPressComRESTAPI.get(path, parameters: parameters, success: { (responseObject, _) in + guard let response = responseObject as? [String: AnyObject], + let users = response["users"] as? [[String: AnyObject]], + let people = try? self.peopleFromResponse(users, siteID: siteID, type: User.self) else { + failure(ResponseError.decodingFailure) + return + } + + let hasMore = self.peopleFoundFromResponse(response) > (offset + people.count) + success(people, hasMore) + + }, failure: { (error, _) in + failure(error) + }) + } + + /// Retrieves the collection of Followers associated to a site. + /// + /// - Parameters: + /// - siteID: The target site's ID. + /// - count: The first N followers to be skipped in the returned array. + /// - size: Number of objects to retrieve. + /// - success: Closure to be executed on success + /// - failure: Closure to be executed on error. + /// + /// - Returns: An array of Followers. + /// + public func getFollowers(_ siteID: Int, + offset: Int = 0, + count: Int, + success: @escaping ((_ followers: [Follower], _ hasMore: Bool) -> Void), + failure: @escaping (Error) -> Void) { + let endpoint = "sites/\(siteID)/follows" + let path = self.path(forEndpoint: endpoint, withVersion: ._1_1) + let pageNumber = (offset / count + 1) + let parameters: [String: AnyObject] = [ + "number": count as AnyObject, + "page": pageNumber as AnyObject, + "fields": "ID, nice_name, first_name, last_name, name, avatar_URL" as AnyObject + ] + + wordPressComRESTAPI.get(path, parameters: parameters, success: { (responseObject, _) in + guard let response = responseObject as? [String: AnyObject], + let followers = response["users"] as? [[String: AnyObject]], + let people = try? self.peopleFromResponse(followers, siteID: siteID, type: Follower.self) else { + failure(ResponseError.decodingFailure) + return + } + + let hasMore = self.peopleFoundFromResponse(response) > (offset + people.count) + success(people, hasMore) + + }, failure: { (error, _) in + failure(error) + }) + } + + /// Retrieves the collection of email followers associated to a site. + /// + /// - Parameters: + /// - siteID: The target site's ID. + /// - page: The page to fetch. + /// - max: The max number of followers to fetch. + /// - success: Closure to be executed on success with an array of EmailFollower and a bool indicating if more pages are available. + /// - failure: Closure to be executed on error. + /// + public func getEmailFollowers(_ siteID: Int, + page: Int = 1, + max: Int = 20, + success: @escaping ((_ followers: [EmailFollower], _ hasMore: Bool) -> Void), + failure: @escaping (Error) -> Void) { + let endpoint = "sites/\(siteID)/stats/followers" + let path = self.path(forEndpoint: endpoint, withVersion: ._1_1) + let parameters: [String: AnyObject] = [ + "page": page as AnyObject, + "max": max as AnyObject, + "type": "email" as AnyObject + ] + + wordPressComRESTAPI.get(path, parameters: parameters, success: { responseObject, _ in + guard let response = responseObject as? [String: AnyObject], + let subscribers = response["subscribers"] as? [[String: AnyObject]], + let totalPages = response["pages"] as? Int else { + failure(ResponseError.decodingFailure) + return + } + let followers = subscribers.compactMap { EmailFollower(siteID: siteID, statsFollower: StatsFollower(jsonDictionary: $0)) } + let hasMore = totalPages > page + success(followers, hasMore) + }, failure: { error, _ in + failure(error) + }) + } + + /// Retrieves the collection of Viewers associated to a site. + /// + /// - Parameters: + /// - siteID: The target site's ID. + /// - count: The first N followers to be skipped in the returned array. + /// - size: Number of objects to retrieve. + /// - success: Closure to be executed on success + /// - failure: Closure to be executed on error. + /// + /// - Returns: An array of Followers. + /// + public func getViewers(_ siteID: Int, + offset: Int = 0, + count: Int, + success: @escaping ((_ followers: [Viewer], _ hasMore: Bool) -> Void), + failure: @escaping (Error) -> Void) { + let endpoint = "sites/\(siteID)/viewers" + let path = self.path(forEndpoint: endpoint, withVersion: ._1_1) + let pageNumber = (offset / count + 1) + let parameters: [String: AnyObject] = [ + "number": count as AnyObject, + "page": pageNumber as AnyObject + ] + + wordPressComRESTAPI.get(path, parameters: parameters, success: { responseObject, _ in + guard let response = responseObject as? [String: AnyObject], + let viewers = response["viewers"] as? [[String: AnyObject]], + let people = try? self.peopleFromResponse(viewers, siteID: siteID, type: Viewer.self) else { + failure(ResponseError.decodingFailure) + return + } + + let hasMore = self.peopleFoundFromResponse(response) > (offset + people.count) + success(people, hasMore) + + }, failure: { (error, _) in + failure(error) + }) + } + + /// Updates a specified User's Role + /// + /// - Parameters: + /// - siteID: The ID of the site associated + /// - personID: The ID of the person to be updated + /// - newRole: The new Role that should be assigned to the user. + /// - success: Optional closure to be executed on success + /// - failure: Optional closure to be executed on error. + /// + /// - Returns: A single User instance. + /// + public func updateUserRole(_ siteID: Int, + userID: Int, + newRole: String, + success: ((RemotePerson) -> Void)? = nil, + failure: ((Error) -> Void)? = nil) { + let endpoint = "sites/\(siteID)/users/\(userID)" + let path = self.path(forEndpoint: endpoint, withVersion: ._1_1) + let parameters = ["roles": [newRole]] + + wordPressComRESTAPI.post(path, + parameters: parameters as [String: AnyObject]?, + success: { (responseObject, _) in + guard let response = responseObject as? [String: AnyObject], + let person = try? self.personFromResponse(response, siteID: siteID, type: User.self) else { + failure?(ResponseError.decodingFailure) + return + } + + success?(person) + }, + failure: { (error, _) in + failure?(error) + }) + } + + /// Deletes or removes a User from a site. + /// + /// - Parameters: + /// - siteID: The ID of the site associated. + /// - userID: The ID of the user to be deleted. + /// - reassignID: When present, all of the posts and pages that belong to `userID` will be reassigned + /// to another person, with the specified ID. + /// - success: Optional closure to be executed on success + /// - failure: Optional closure to be executed on error. + /// + public func deleteUser(_ siteID: Int, + userID: Int, + reassignID: Int? = nil, + success: (() -> Void)? = nil, + failure: ((Error) -> Void)? = nil) { + let endpoint = "sites/\(siteID)/users/\(userID)/delete" + let path = self.path(forEndpoint: endpoint, withVersion: ._1_1) + var parameters = [String: AnyObject]() + + if let reassignID { + parameters["reassign"] = reassignID as AnyObject? + } + + wordPressComRESTAPI.post(path, parameters: nil, success: { (_, _) in + success?() + }, failure: { (error, _) in + failure?(error) + }) + } + + /// Deletes or removes a Follower from a site. + /// + /// - Parameters: + /// - siteID: The ID of the site associated. + /// - userID: The ID of the follower to be deleted. + /// - success: Optional closure to be executed on success + /// - failure: Optional closure to be executed on error. + /// + @objc public func deleteFollower(_ siteID: Int, + userID: Int, + success: (() -> Void)? = nil, + failure: ((Error) -> Void)? = nil) { + let endpoint = "sites/\(siteID)/followers/\(userID)/delete" + let path = self.path(forEndpoint: endpoint, withVersion: ._1_1) + + wordPressComRESTAPI.post(path, parameters: nil, success: { (_, _) in + success?() + }, failure: { (error, _) in + failure?(error) + }) + } + + /// Deletes or removes an Email Follower from a site. + /// + /// - Parameters: + /// - siteID: The ID of the site associated. + /// - userID: The ID of the email follower to be deleted. + /// - success: Optional closure to be executed on success + /// - failure: Optional closure to be executed on error. + /// + @objc public func deleteEmailFollower(_ siteID: Int, + userID: Int, + success: (() -> Void)? = nil, + failure: ((Error) -> Void)? = nil) { + let endpoint = "sites/\(siteID)/email-followers/\(userID)/delete" + let path = self.path(forEndpoint: endpoint, withVersion: ._1_1) + + wordPressComRESTAPI.post(path, parameters: nil, success: { _, _ in + success?() + }, failure: { error, _ in + failure?(error) + }) + } + + /// Deletes or removes a User from a site. + /// + /// - Parameters: + /// - siteID: The ID of the site associated. + /// - userID: The ID of the viewer to be deleted. + /// - success: Optional closure to be executed on success + /// - failure: Optional closure to be executed on error. + /// + @objc public func deleteViewer(_ siteID: Int, + userID: Int, + success: (() -> Void)? = nil, + failure: ((Error) -> Void)? = nil) { + let endpoint = "sites/\(siteID)/viewers/\(userID)/delete" + let path = self.path(forEndpoint: endpoint, withVersion: ._1_1) + + wordPressComRESTAPI.post(path, parameters: nil, success: { (_, _) in + success?() + }, failure: { (error, _) in + failure?(error) + }) + } + + /// Retrieves all of the Available Roles, for a given SiteID. + /// + /// - Parameters: + /// - siteID: The ID of the site associated. + /// - success: Optional closure to be executed on success. + /// - failure: Optional closure to be executed on error. + /// + /// - Returns: An array of Person.Role entities. + /// + public func getUserRoles(_ siteID: Int, + success: @escaping (([RemoteRole]) -> Void), + failure: ((Error) -> Void)? = nil) { + let endpoint = "sites/\(siteID)/roles" + let path = self.path(forEndpoint: endpoint, withVersion: ._1_1) + + wordPressComRESTAPI.get(path, parameters: nil, success: { (responseObject, _) in + guard let response = responseObject as? [String: AnyObject], + let roles = try? self.rolesFromResponse(response) else { + failure?(ResponseError.decodingFailure) + return + } + + success(roles) + }, failure: { (error, _) in + failure?(error) + }) + } + + /// Validates Invitation Recipients. + /// + /// - Parameters: + /// - siteID: The ID of the site associated. + /// - usernameOrEmail: Recipient that should be validated. + /// - role: Role that would be granted to the recipient. + /// - success: Closure to be executed on success. + /// - failure: Closure to be executed on failure. The remote error will be passed on. + /// + @objc public func validateInvitation(_ siteID: Int, + usernameOrEmail: String, + role: String, + success: @escaping (() -> Void), + failure: @escaping ((Error) -> Void)) { + let endpoint = "sites/\(siteID)/invites/validate" + let path = self.path(forEndpoint: endpoint, withVersion: ._1_1) + + let parameters = [ + "invitees": usernameOrEmail, + "role": role + ] + + wordPressComRESTAPI.post(path, parameters: parameters as [String: AnyObject]?, success: { (responseObject, _) in + guard let responseDict = responseObject as? [String: AnyObject] else { + failure(ResponseError.decodingFailure) + return + } + + if let error = self.errorFromInviteResponse(responseDict, usernameOrEmail: usernameOrEmail) { + failure(error) + return + } + + success() + + }, failure: { (error, _) in + failure(error) + }) + } + + /// Sends an Invitation to the specified recipient. + /// + /// - Parameters: + /// - siteID: The ID of the associated site. + /// - usernameOrEmail: Recipient that should receive the invite. + /// - role: Role that would be granted to the recipient. + /// - message: String that should be sent to the recipient. + /// - success: Closure to be executed on success. + /// - failure: Closure to be executed on failure. The remote error will be passed on. + /// + @objc public func sendInvitation(_ siteID: Int, + usernameOrEmail: String, + role: String, + message: String, + success: @escaping (() -> Void), + failure: @escaping ((Error) -> Void)) { + let endpoint = "sites/\(siteID)/invites/new" + let path = self.path(forEndpoint: endpoint, withVersion: ._1_1) + + let parameters = [ + "invitees": usernameOrEmail, + "role": role, + "message": message + ] + + wordPressComRESTAPI.post(path, parameters: parameters as [String: AnyObject]?, success: { (responseObject, _) in + guard let responseDict = responseObject as? [String: AnyObject] else { + failure(ResponseError.decodingFailure) + return + } + + if let error = self.errorFromInviteResponse(responseDict, usernameOrEmail: usernameOrEmail) { + failure(error) + return + } + + success() + + }, failure: { (error, _) in + failure(error) + }) + } + + /// Fetch any existing invite links. + /// + /// - Parameters: + /// - siteID: The site ID for the invite links. + /// - success: A success block accepting an array of invite links as an argument. + /// - failure: Closure to be executed on failure. The remote error will be passed on. + /// + public func fetchInvites(_ siteID: Int, + success: @escaping (([RemoteInviteLink]) -> Void), + failure: @escaping ((Error) -> Void)) { + let endpoint = "sites/\(siteID)/invites" + let path = self.path(forEndpoint: endpoint, withVersion: ._1_1) + + let params = [ + "status": "all", + "number": 100 + ] as [String: AnyObject] + + wordPressComRESTAPI.get(path, parameters: params, success: { (responseObject, _) in + guard let responseDict = responseObject as? [String: AnyObject] else { + failure(ResponseError.decodingFailure) + return + } + + var results = [RemoteInviteLink]() + if let links = responseDict["links"] as? [[String: Any]] { + for link in links { + results.append(RemoteInviteLink(dict: link)) + } + } + success(results) + + }, failure: { (error, _) in + failure(error) + }) + } + + /// Create a new batch of invite links. + /// + /// - Parameters: + /// - siteID: The site ID for the invite links. + /// - success: A success block accepting an array of invite links as an argument. + /// - failure: Closure to be executed on failure. The remote error will be passed on. + /// + public func generateInviteLinks(_ siteID: Int, + success: @escaping (([RemoteInviteLink]) -> Void), + failure: @escaping ((Error) -> Void)) { + let endpoint = "sites/\(siteID)/invites/links/generate" + let path = self.path(forEndpoint: endpoint, withVersion: ._2_0) + + wordPressComRESTAPI.post(path, parameters: nil, success: { (responseObject, _) in + guard let responseArray = responseObject as? [[String: AnyObject]] else { + failure(ResponseError.decodingFailure) + return + } + + var results = [RemoteInviteLink]() + for dict in responseArray { + results.append(RemoteInviteLink(dict: dict)) + } + success(results) + + }, failure: { (error, _) in + failure(error) + }) + + } + + /// Disable any existing invite links. + /// + /// - Parameters: + /// - siteID: The site ID for the invite links to disable. + /// - success: A success block. + /// - failure: A failure block + /// + public func disableInviteLinks(_ siteID: Int, + success: @escaping (([String]) -> Void), + failure: @escaping ((Error) -> Void)) { + let endpoint = "sites/\(siteID)/invites/links/disable" + let path = self.path(forEndpoint: endpoint, withVersion: ._2_0) + + wordPressComRESTAPI.post(path, parameters: nil, success: { (responseObject, _) in + let deletedKeys = responseObject as? [String] ?? [String]() + success(deletedKeys) + + }, failure: { (error, _) in + failure(error) + }) + } + +} + +/// Encapsulates PeopleServiceRemote Private Methods +/// +private extension PeopleServiceRemote { + /// Parses a dictionary containing an array of RemotePersons, and returns an array of RemotePersons instances. + /// + /// - Parameters: + /// - response: Raw array of entity dictionaries + /// - siteID: the ID of the site associated + /// - type: The kind of Person we should parse. + /// + /// - Returns: An array of *RemotePerson* instances. + /// + func peopleFromResponse(_ rawPeople: [[String: AnyObject]], + siteID: Int, + type: T.Type) throws -> [T] { + let people = try rawPeople.compactMap { (user) -> T? in + return try personFromResponse(user, siteID: siteID, type: type) + } + + return people + } + + /// Parses a dictionary representing a RemotePerson, and returns an instance. + /// + /// - Parameters: + /// - response: Raw backend dictionary + /// - siteID: the ID of the site associated + /// - type: The kind of Person we should parse. + /// + /// - Returns: A single *Person* instance. + /// + func personFromResponse(_ user: [String: AnyObject], + siteID: Int, + type: T.Type) throws -> T { + guard let ID = user["ID"] as? Int else { + throw ResponseError.decodingFailure + } + + guard let username = user["nice_name"] as? String else { + throw ResponseError.decodingFailure + } + + guard let displayName = user["name"] as? String else { + throw ResponseError.decodingFailure + } + + let firstName = user["first_name"] as? String + let lastName = user["last_name"] as? String + let avatarURL = (user["avatar_URL"] as? NSString) + .flatMap { URL(string: $0.wpkit_stringByUrlEncoding())} + + let linkedUserID = user["linked_user_ID"] as? Int ?? ID + let isSuperAdmin = user["is_super_admin"] as? Bool ?? false + let roles = user["roles"] as? [String] + + let role = roles?.first ?? "" + + return T(ID: ID, + username: username, + firstName: firstName, + lastName: lastName, + displayName: displayName, + role: role, + siteID: siteID, + linkedUserID: linkedUserID, + avatarURL: avatarURL, + isSuperAdmin: isSuperAdmin) + } + + /// Returns the count of persons that can be retrieved from the backend. + /// + /// - Parameters response: Raw backend dictionary + /// + func peopleFoundFromResponse(_ response: [String: AnyObject]) -> Int { + return response["found"] as? Int ?? 0 + } + + /// Parses a collection of Roles, and returns instances of the RemotePerson.Role Enum. + /// + /// - Parameter roles: Raw backend dictionary + /// + /// - Returns: Collection of the remote roles. + /// + func rolesFromResponse(_ roles: [String: AnyObject]) throws -> [RemoteRole] { + guard let rawRoles = roles["roles"] as? [[String: AnyObject]] else { + throw ResponseError.decodingFailure + } + + let parsed = try rawRoles.map { (rawRole) -> RemoteRole in + guard let name = rawRole["name"] as? String, + let displayName = rawRole["display_name"] as? String else { + throw ResponseError.decodingFailure + } + + return RemoteRole(slug: name, name: displayName) + } + + return parsed + } + + /// Parses a remote Invitation Error into a PeopleServiceRemote.Error. + /// + /// - Parameters: + /// - response: Raw backend dictionary + /// - usernameOrEmail: Recipient that was used to either validate, or effectively send an invite. + /// + /// - Returns: The remote error, if any. + /// + func errorFromInviteResponse(_ response: [String: AnyObject], usernameOrEmail: String) -> Error? { + guard let errors = response["errors"] as? [String: AnyObject], + let theError = errors[usernameOrEmail] as? [String: String], + let code = theError["code"] else { + return nil + } + + switch code { + case "invalid_input": + return ResponseError.invalidInputError + case "invalid_input_has_role": + return ResponseError.userAlreadyHasRoleError + case "invalid_input_following": + return ResponseError.userAlreadyHasRoleError + default: + return ResponseError.unknownError + } + } +} diff --git a/Modules/Sources/WordPressKit/PlanServiceRemote.swift b/Modules/Sources/WordPressKit/PlanServiceRemote.swift new file mode 100644 index 000000000000..4f8000c21f0a --- /dev/null +++ b/Modules/Sources/WordPressKit/PlanServiceRemote.swift @@ -0,0 +1,212 @@ +import Foundation +import WordPressKitObjC + +open class PlanServiceRemote: ServiceRemoteWordPressComREST { + public typealias AvailablePlans = (plans: [RemoteWpcomPlan], groups: [RemotePlanGroup], features: [RemotePlanFeature]) + + typealias EndpointResponse = [String: AnyObject] + + public enum ResponseError: Int, Error { + // Error decoding JSON + case decodingFailure + // Depricated. An unsupported plan. + case unsupportedPlan + // Deprecated. No active plan identified in the results. + case noActivePlan + } + + // MARK: - Endpoints + + /// Get the list of WordPress.com plans, their descriptions, and their features. + /// + public func getWpcomPlans(_ success: @escaping (AvailablePlans) -> Void, failure: @escaping (Error) -> Void) { + let endpoint = "plans/mobile" + let path = self.path(forEndpoint: endpoint, withVersion: ._2_0) + + wordPressComRESTAPI.get(path, + parameters: nil, + success: { + response, _ in + + guard let response = response as? EndpointResponse else { + failure(PlanServiceRemote.ResponseError.decodingFailure) + return + } + + let plans = self.parseWpcomPlans(response) + let groups = self.parseWpcomPlanGroups(response) + let features = self.parseWpcomPlanFeatures(response) + + success((plans, groups, features)) + }, failure: { + error, _ in + failure(error) + }) + } + + /// Fetch the plan ID and name for each of the user's sites. + /// Accepts locale as a parameter in order to override automatic localization + /// and return non-localized results when needed. + /// + public func getPlanDescriptionsForAllSitesForLocale(_ locale: String, success: @escaping ([Int: RemotePlanSimpleDescription]) -> Void, failure: @escaping (Error) -> Void) { + let endpoint = "me/sites" + let path = self.path(forEndpoint: endpoint, withVersion: ._1_1) + let parameters: [String: String] = [ + "fields": "ID, plan", + "locale": locale + ] + + wordPressComRESTAPI.get(path, + parameters: parameters as [String: AnyObject], + success: { + response, _ in + + guard let response = response as? EndpointResponse else { + failure(PlanServiceRemote.ResponseError.decodingFailure) + return + } + + let result = self.parsePlanDescriptionsForSites(response) + success(result) + }, + failure: { + error, _ in + failure(error) + }) + } + + // MARK: - Non-public methods + + func parsePlanDescriptionsForSites(_ response: EndpointResponse) -> [Int: RemotePlanSimpleDescription] { + var result = [Int: RemotePlanSimpleDescription]() + + guard let sites = response["sites"] as? [EndpointResponse] else { + return result + } + + for site in sites { + guard + let tpl = parsePlanDescriptionForSite(site) + else { + continue + } + result[tpl.siteID] = tpl.plan + } + + return result + } + + func parsePlanDescriptionForSite(_ site: EndpointResponse) -> (siteID: Int, plan: RemotePlanSimpleDescription)? { + guard + let siteID = site["ID"] as? Int, + let plan = site["plan"] as? EndpointResponse, + let planID = plan["product_id"] as? Int, + let planName = plan["product_name_short"] as? String, + let planSlug = plan["product_slug"] as? String else { + return nil + } + + var name = planName + if planSlug.contains("jetpack") { + name = name + " (Jetpack)" + } + + return (siteID, RemotePlanSimpleDescription(planID: planID, name: name)) + } + + func parseWpcomPlans(_ response: EndpointResponse) -> [RemoteWpcomPlan] { + guard let json = response["plans"] as? [EndpointResponse] else { + return [RemoteWpcomPlan]() + } + + return json.compactMap { parseWpcomPlan($0) } + } + + func parseWpcomPlanProducts(_ products: [EndpointResponse]) -> String { + let parsedResult = products.compactMap { $0["plan_id"] as? String } + return parsedResult.joined(separator: ",") + } + + func parseWpcomPlanGroups(_ response: EndpointResponse) -> [RemotePlanGroup] { + guard let json = response["groups"] as? [EndpointResponse] else { + return [RemotePlanGroup]() + } + return json.compactMap { parsePlanGroup($0) } + } + + func parseWpcomPlanFeatures(_ response: EndpointResponse) -> [RemotePlanFeature] { + guard let json = response["features"] as? [EndpointResponse] else { + return [RemotePlanFeature]() + } + return json.compactMap { parsePlanFeature($0) } + } + + func parseWpcomPlan(_ item: EndpointResponse) -> RemoteWpcomPlan? { + guard + let groups = (item["groups"] as? [String])?.joined(separator: ","), + let productsArray = item["products"] as? [EndpointResponse], + let name = item["name"] as? String, + let shortname = item["short_name"] as? String, + let tagline = item["tagline"] as? String, + let description = item["description"] as? String, + let features = (item["features"] as? [String])?.joined(separator: ","), + let icon = item["icon"] as? String, + let supportPriority = item["support_priority"] as? Int, + let supportName = item["support_name"] as? String, + let nonLocalizedShortname = item["nonlocalized_short_name"] as? String else { + return nil + } + + let products = parseWpcomPlanProducts(productsArray) + + return RemoteWpcomPlan(groups: groups, + products: products, + name: name, + shortname: shortname, + tagline: tagline, + description: description, + features: features, + icon: icon, + supportPriority: supportPriority, + supportName: supportName, + nonLocalizedShortname: nonLocalizedShortname) + } + + func parsePlanGroup(_ item: EndpointResponse) -> RemotePlanGroup? { + guard + let slug = item["slug"] as? String, + let name = item["name"] as? String else { + return nil + } + return RemotePlanGroup(slug: slug, name: name) + } + + func parsePlanFeature(_ item: EndpointResponse) -> RemotePlanFeature? { + guard + let slug = item["id"] as? String, + let title = item["name"] as? String, + let description = item["description"] as? String else { + return nil + } + return RemotePlanFeature(slug: slug, title: title, description: description, iconURL: nil) + } + + /// Retrieves Zendesk meta data: plan and Jetpack addons, if available + open func getZendeskMetadata(siteID: Int, completion: @escaping (Result) -> Void) { + let endpoint = "me/sites" + let path = self.path(forEndpoint: endpoint, withVersion: ._1_1) + let parameters = ["fields": "ID, zendesk_site_meta"] as [String: AnyObject] + + Task { @MainActor [wordPressComRestApi] in + await wordPressComRestApi.perform(.get, URLString: path, parameters: parameters, type: ZendeskSiteContainer.self) + .eraseToError() + .flatMap { container in + guard let metadata = container.body.sites.filter({ $0.ID == siteID }).first?.zendeskMetadata else { + return .failure(PlanServiceRemoteError.noMetadata) + } + return .success(metadata) + } + .execute(completion) + } + } +} diff --git a/Modules/Sources/WordPressKit/PlanServiceRemote_ApiVersion1_3.swift b/Modules/Sources/WordPressKit/PlanServiceRemote_ApiVersion1_3.swift new file mode 100644 index 000000000000..ccbb97edb871 --- /dev/null +++ b/Modules/Sources/WordPressKit/PlanServiceRemote_ApiVersion1_3.swift @@ -0,0 +1,63 @@ +import Foundation +import WordPressKitObjC + +@objc public class PlanServiceRemote_ApiVersion1_3: ServiceRemoteWordPressComREST { + + public typealias SitePlans = (activePlan: RemotePlan_ApiVersion1_3, availablePlans: [RemotePlan_ApiVersion1_3]) + + public func getPlansForSite(_ siteID: Int, + success: @escaping (SitePlans) -> Void, + failure: @escaping (Error) -> Void) { + let endpoint = "sites/\(siteID)/plans" + let path = self.path(forEndpoint: endpoint, withVersion: ._1_3) + + wordPressComRESTAPI.get( + path, + parameters: nil, + success: { response, _ in + do { + try success(PlanServiceRemote_ApiVersion1_3.mapPlansResponse(response)) + } catch { + WPKitLogError("Error parsing plans response for site \(siteID)") + WPKitLogError("\(error)") + WPKitLogDebug("Full response: \(response)") + failure(error) + } + }, + failure: { error, _ in + failure(error) + } + ) + } + + private static func mapPlansResponse(_ response: Any) throws + -> (activePlan: RemotePlan_ApiVersion1_3, availablePlans: [RemotePlan_ApiVersion1_3]) { + + guard let json = response as? [String: AnyObject] else { + throw PlanServiceRemote.ResponseError.decodingFailure + } + + var activePlans: [RemotePlan_ApiVersion1_3] = [] + var currentlyActivePlan: RemotePlan_ApiVersion1_3? + + try json.forEach { (key, value) in + let data = try JSONSerialization.data(withJSONObject: value, options: .prettyPrinted) + do { + let decodedResult = try JSONDecoder.apiDecoder.decode(RemotePlan_ApiVersion1_3.self, from: data) + decodedResult.planID = key + activePlans.append(decodedResult) + if decodedResult.isCurrentPlan { + currentlyActivePlan = decodedResult + } + } catch let error { + WPKitLogError("Error parsing plans response for site \(error)") + } + } + + guard let activePlan = currentlyActivePlan else { + throw PlanServiceRemote.ResponseError.noActivePlan + } + return (activePlan, activePlans) + } + +} diff --git a/Modules/Sources/WordPressKit/PluginDirectoryEntry.swift b/Modules/Sources/WordPressKit/PluginDirectoryEntry.swift new file mode 100644 index 000000000000..0fbbb7941242 --- /dev/null +++ b/Modules/Sources/WordPressKit/PluginDirectoryEntry.swift @@ -0,0 +1,294 @@ +import Foundation + +public struct PluginDirectoryEntry { + public let name: String + public let slug: String + public let version: String? + public let lastUpdated: Date? + + public let icon: URL? + public let banner: URL? + + public let author: String + public let authorURL: URL? + + let descriptionHTML: String? + let installationHTML: String? + let faqHTML: String? + let changelogHTML: String? + + public var descriptionText: NSAttributedString? { + return extractHTMLText(self.descriptionHTML) + } + public var installationText: NSAttributedString? { + return extractHTMLText(self.installationHTML) + } + public var faqText: NSAttributedString? { + return extractHTMLText(self.faqHTML) + } + public var changelogText: NSAttributedString? { + return extractHTMLText(self.changelogHTML) + } + + let rating: Double + public var starRating: Double { + return (rating / 10).rounded() / 2 + // rounded to nearest half. + } +} + +extension PluginDirectoryEntry: Equatable { + public static func ==(lhs: PluginDirectoryEntry, rhs: PluginDirectoryEntry) -> Bool { + return lhs.name == rhs.name + && lhs.slug == rhs.slug + && lhs.version == rhs.version + && lhs.lastUpdated == rhs.lastUpdated + && lhs.icon == rhs.icon + } +} + +extension PluginDirectoryEntry: Codable { + private enum CodingKeys: String, CodingKey { + case name + case slug + case version + case lastUpdated = "last_updated" + case icons + case author + case rating + + case banners + + case sections + } + + private enum BannersKeys: String, CodingKey { + case high + case low + } + + private enum SectionKeys: String, CodingKey { + case description + case installation + case faq + case changelog + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let decodedName = try container.decode(String.self, forKey: .name) + name = decodedName.wpkit_stringByDecodingXMLCharacters() + slug = try container.decode(String.self, forKey: .slug) + version = try? container.decode(String.self, forKey: .version) + lastUpdated = try? container.decode(Date.self, forKey: .lastUpdated) + rating = try container.decode(Double.self, forKey: .rating) + + let icons = try? container.decodeIfPresent([String: String].self, forKey: .icons) + icon = icons?["2x"].flatMap({ (s) -> URL? in + URL(string: s) + }) + + // If there's no hi-res version of the banner, the API returns `high: false`, instead of something more logical, + // like an empty string or `null`, hence the dance below. + let banners = try? container.nestedContainer(keyedBy: BannersKeys.self, forKey: .banners) + + if let highRes = try? banners?.decodeIfPresent(String.self, forKey: .high) { + banner = URL(string: highRes) + } else if let lowRes = try? banners?.decodeIfPresent(String.self, forKey: .low) { + banner = URL(string: lowRes) + } else { + banner = nil + } + + (author, authorURL) = try extractAuthor(container.decode(String.self, forKey: .author)) + + let sections = try? container.nestedContainer(keyedBy: SectionKeys.self, forKey: .sections) + + descriptionHTML = try trimTags(sections?.decodeIfPresent(String.self, forKey: .description)) + installationHTML = try trimTags(sections?.decodeIfPresent(String.self, forKey: .installation)) + faqHTML = try trimTags(sections?.decodeIfPresent(String.self, forKey: .faq)) + + let changelog = try sections?.decodeIfPresent(String.self, forKey: .changelog) + changelogHTML = trimTags(trimChangelog(changelog)) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + try container.encode(name.wpkit_stringByEncodingXMLCharacters(), forKey: .name) + try container.encode(slug, forKey: .slug) + try container.encodeIfPresent(version, forKey: .version) + try container.encodeIfPresent(lastUpdated, forKey: .lastUpdated) + try container.encode(rating, forKey: .rating) + + if icon != nil { + try container.encode(["2x": icon], forKey: .icons) + } + + if banner != nil { + try container.encode([BannersKeys.high.rawValue: banner], forKey: .banners) + } + + if let url = authorURL { + try container.encode("\(author)", forKey: .author) + } else { + try container.encode(author, forKey: .author) + } + + let sections: [String: String] = [SectionKeys.changelog: changelogHTML, + SectionKeys.description: descriptionHTML, + SectionKeys.faq: faqHTML, + SectionKeys.installation: installationHTML].reduce([:]) { + var newValue = $0 + if let value = $1.value { + newValue[$1.key.rawValue] = value + } + return newValue + } + + try container.encode(sections, forKey: .sections) + } + + internal init(responseObject: [String: AnyObject]) throws { + // Data returned by the featured plugins API endpoint is almost exactly in the same format + // as the data from the Plugin Directory, with few fields missing (updateDate, version, etc). + // In order to avoid duplicating almost identical entites, we provide a special initializer + // that `nil`s out those fields. + + guard let name = responseObject["name"] as? String, + let slug = responseObject["slug"] as? String, + let authorString = responseObject["author"] as? String, + let rating = responseObject["rating"] as? Double else { + throw PluginServiceRemote.ResponseError.decodingFailure + } + + self.name = name + self.slug = slug + self.rating = rating + self.author = extractAuthor(authorString).name + + if let icon = (responseObject["icons"]?["2x"] as? String).flatMap({ URL(string: $0) }) { + self.icon = icon + } else { + self.icon = (responseObject["icons"]?["1x"] as? String).flatMap { URL(string: $0) } + } + + self.authorURL = nil + self.version = nil + self.lastUpdated = nil + self.banner = nil + self.descriptionHTML = nil + self.installationHTML = nil + self.faqHTML = nil + self.changelogHTML = nil + } +} + +// Since the WPOrg API returns `author` as a HTML string (or freeform text), we need to get ugly and parse out the important bits out of it ourselves. +// Using the built-in NSAttributedString API for it is too slow — it's required to run on main thread and it calls out to WebKit APIs, +// making the context switches excessively expensive when trying to display a list of plugins. +typealias Author = (name: String, link: URL?) + +internal func extractAuthor(_ author: String) -> Author { + // Because the `author` field is so free-form, there's cases of it being + // * regular string ("Gutenberg") + // * URL ("https://wordpress.org/plugins/gutenberg/#reviews&arg=1") + // * HTML link ("Gutenberg" + // but also fun things like + // * malformed HTML: "Gutenberg". + // To save ourselves a headache of trying to support all those edge-cases when parsing out the + // user-facing name, let's just employ honest to god XMLParser and be done with it. + // (h/t @koke for suggesting and writing the XMLParser approach) + + guard let data = author + .replacingOccurrences(of: "&", with: "&") // can't have naked "&" in XML, but they're valid in URLs. + .data(using: .utf8) else { + return (author, nil) + } + + let parser = XMLParser(data: data) + let delegate = AuthorParser() + + parser.delegate = delegate + + guard parser.parse() else { + if let url = URL(string: author), + url.scheme != nil { + return (author, url) + } else { + return (author, nil) + } + } + + return (delegate.author, delegate.url) +} + +internal func extractHTMLText(_ text: String?) -> NSAttributedString? { + guard Thread.isMainThread, + let data = text?.data(using: .utf16), + let attributedString = try? NSAttributedString(data: data, options: [.documentType: NSAttributedString.DocumentType.html], documentAttributes: nil) else { + return nil + } + + return attributedString +} + +internal func trimTags(_ htmlString: String?) -> String? { + // Because the HTML we get from backend can contain literally anything, we need to set some limits as to what we won't even try to parse. + let tagsToRemove = ["script", "iframe"] + + guard var html = htmlString else { return nil } + + for tag in tagsToRemove { + let openingTag = "<\(tag)" + let closingTag = "/\(tag)>" + + if let openingRange = html.range(of: openingTag), + let closingRange = html.range(of: closingTag) { + + let rangeToRemove = openingRange.lowerBound.. String? { + // The changelog that some plugins return is HUGE — Gutenberg as of 2.0 for example returns over 50KiB of text and 1000s of lines, + // Akismet has changelog going back to 2009, etc — there isn't any backend-enforced limit, but thankfully there is backend-enforced structure. + // Showing more than last versions rel-notes seems somewhat poinless (and unlike how, e.g. App Store works), so we trim it to just the + // latest version here. If the user wants to see the whole thing, they can open the plugin's page in WPOrg directory in a browser. + guard let log = changelog else { return nil } + + guard let firstOccurence = log.range(of: "

") else { + // Each "version" in the changelog is delineated by the version number wrapped in `

`. + // If the payload doesn't follow the format we're expecting, let's not trim it and return what we received. + return log + } + + let rangeAfterFirstOccurence = firstOccurence.upperBound ..< log.endIndex + guard let secondOccurence = log.range(of: "

", range: rangeAfterFirstOccurence) else { + // Same as above. If the data doesn't the format we're expecting, bail. + return log + } + + return String(log[log.startIndex ..< secondOccurence.lowerBound]) +} + +private final class AuthorParser: NSObject, XMLParserDelegate { + var author = "" + var url: URL? + + func parser(_ parser: XMLParser, didStartElement elementName: String, namespaceURI: String?, qualifiedName qName: String?, attributes attributeDict: [String: String] = [:]) { + guard elementName == "a", + let href = attributeDict["href"] else { + return + } + url = URL(string: href) + } + + func parser(_ parser: XMLParser, foundCharacters string: String) { + author.append(string) + } +} diff --git a/Modules/Sources/WordPressKit/PluginDirectoryFeedPage.swift b/Modules/Sources/WordPressKit/PluginDirectoryFeedPage.swift new file mode 100644 index 000000000000..af6d1d2c7c8e --- /dev/null +++ b/Modules/Sources/WordPressKit/PluginDirectoryFeedPage.swift @@ -0,0 +1,55 @@ +import Foundation + +public struct PluginDirectoryFeedPage: Decodable, Equatable { + public let pageMetadata: PluginDirectoryPageMetadata + public let plugins: [PluginDirectoryEntry] + + private enum CodingKeys: String, CodingKey { + case info + case plugins + } + + private enum InfoKeys: String, CodingKey { + case page + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + // The API we're using has a bug where sometimes the `plugins` field is an Array, and sometimes + // it's a dictionary with numerical keys. Until the responsible parties can deploy a patch, + // here's a workaround. + do { + if let parsedPlugins = try? container.decode([PluginDirectoryEntry].self, forKey: .plugins) { + plugins = parsedPlugins + } else { + let parsedPlugins = try container.decode([Int: PluginDirectoryEntry].self, forKey: .plugins) + plugins = parsedPlugins + .sorted { $0.key < $1.key } + .compactMap { $0.value } + } + } + + let info = try container.nestedContainer(keyedBy: InfoKeys.self, forKey: .info) + + let pageNumber = try info.decode(Int.self, forKey: .page) + + pageMetadata = PluginDirectoryPageMetadata(page: pageNumber, pluginSlugs: plugins.map { $0.slug}) + } + + public static func ==(lhs: PluginDirectoryFeedPage, rhs: PluginDirectoryFeedPage) -> Bool { + return lhs.pageMetadata == rhs.pageMetadata + && lhs.plugins == rhs.plugins + } + +} + +public struct PluginDirectoryPageMetadata: Equatable, Codable { + public let page: Int + public let pluginSlugs: [String] + + public static func ==(lhs: PluginDirectoryPageMetadata, rhs: PluginDirectoryPageMetadata) -> Bool { + return lhs.page == rhs.page + && lhs.pluginSlugs == rhs.pluginSlugs + } +} diff --git a/Modules/Sources/WordPressKit/PluginDirectoryServiceRemote.swift b/Modules/Sources/WordPressKit/PluginDirectoryServiceRemote.swift new file mode 100644 index 000000000000..abc04b82b3a0 --- /dev/null +++ b/Modules/Sources/WordPressKit/PluginDirectoryServiceRemote.swift @@ -0,0 +1,146 @@ +import Foundation + +private struct PluginDirectoryRemoteConstants { + static let dateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "YYYY-MM-dd h:mma z" + return formatter + }() + + static let jsonDecoder: JSONDecoder = { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .formatted(PluginDirectoryRemoteConstants.dateFormatter) + return decoder + }() + + static let pluginsPerPage = 50 + + static let getInformationEndpoint = URL(string: "https://api.wordpress.org/plugins/info/1.0/")! + static let feedEndpoint = URL(string: "https://api.wordpress.org/plugins/info/1.1/")! + // note that this _isn't_ the same URL as PluginDirectoryGetInformationEndpoint. +} + +public enum PluginDirectoryFeedType: Hashable { + case popular + case newest + case search(term: String) + + public var slug: String { + switch self { + case .popular: + return "popular" + case .newest: + return "newest" + case .search(let term): + return "search:\(term)" + } + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(slug) + } + + public static func ==(lhs: PluginDirectoryFeedType, rhs: PluginDirectoryFeedType) -> Bool { + return lhs.slug == rhs.slug + } +} + +public struct PluginDirectoryGetInformationEndpoint { + public enum Error: Swift.Error { + case pluginNotFound + } + + let slug: String + public init(slug: String) { + self.slug = slug + } + + func buildRequest() throws -> URLRequest { + try HTTPRequestBuilder(url: PluginDirectoryRemoteConstants.getInformationEndpoint) + .appendURLString("\(slug).json") + .query(name: "fields", value: "icons,banners") + .build() + } + + func parseResponse(data: Data) throws -> PluginDirectoryEntry { + return try PluginDirectoryRemoteConstants.jsonDecoder.decode(PluginDirectoryEntry.self, from: data) + } + + func validate(response: HTTPURLResponse, data: Data?) throws { + // api.wordpress.org has an odd way of responding to plugin info requests for + // plugins not in the directory: it will return `null` with an HTTP 200 OK. + // This turns that case into a `.pluginNotFound` error. + if response.statusCode == 200, + let data, + data.count == 4, + String(data: data, encoding: .utf8) == "null" { + throw Error.pluginNotFound + } + } +} + +public struct PluginDirectoryFeedEndpoint { + public enum Error: Swift.Error { + case genericError + } + + let feedType: PluginDirectoryFeedType + let pageNumber: Int + + init(feedType: PluginDirectoryFeedType) { + self.feedType = feedType + self.pageNumber = 1 + } + + func buildRequest() throws -> URLRequest { + var parameters: [String: Any] = ["action": "query_plugins", + "request[per_page]": PluginDirectoryRemoteConstants.pluginsPerPage, + "request[fields][icons]": 1, + "request[fields][banners]": 1, + "request[fields][sections]": 0, + "request[page]": pageNumber] + switch feedType { + case .popular: + parameters["request[browse]"] = "popular" + case .newest: + parameters["request[browse]"] = "new" + case .search(let term): + parameters["request[search]"] = term + + } + + return try HTTPRequestBuilder(url: PluginDirectoryRemoteConstants.feedEndpoint) + .query(parameters) + .build() + } + + func parseResponse(data: Data) throws -> PluginDirectoryFeedPage { + return try PluginDirectoryRemoteConstants.jsonDecoder.decode(PluginDirectoryFeedPage.self, from: data) + } + + func validate(response: HTTPURLResponse, data: Data?) throws { + if response.statusCode != 200 { throw Error.genericError} + } +} + +public struct PluginDirectoryServiceRemote { + + public init() {} + + public func getPluginFeed(_ feedType: PluginDirectoryFeedType, pageNumber: Int = 1) async throws -> PluginDirectoryFeedPage { + let endpoint = PluginDirectoryFeedEndpoint(feedType: feedType) + let (data, response) = try await URLSession.shared.data(for: endpoint.buildRequest()) + let httpResponse = response as! HTTPURLResponse + try endpoint.validate(response: httpResponse, data: data) + return try endpoint.parseResponse(data: data) + } + + public func getPluginInformation(slug: String) async throws -> PluginDirectoryEntry { + let endpoint = PluginDirectoryGetInformationEndpoint(slug: slug) + let (data, response) = try await URLSession.shared.data(for: endpoint.buildRequest()) + let httpResponse = response as! HTTPURLResponse + try endpoint.validate(response: httpResponse, data: data) + return try endpoint.parseResponse(data: data) + } +} diff --git a/Modules/Sources/WordPressKit/PluginManagementClient.swift b/Modules/Sources/WordPressKit/PluginManagementClient.swift new file mode 100644 index 000000000000..a9d6d44b4645 --- /dev/null +++ b/Modules/Sources/WordPressKit/PluginManagementClient.swift @@ -0,0 +1,13 @@ +import Foundation + +public protocol PluginManagementClient { + func getPlugins(success: @escaping (SitePlugins) -> Void, failure: @escaping (Error) -> Void) + func updatePlugin(pluginID: String, success: @escaping (PluginState) -> Void, failure: @escaping (Error) -> Void) + func activatePlugin(pluginID: String, success: @escaping () -> Void, failure: @escaping (Error) -> Void) + func deactivatePlugin(pluginID: String, success: @escaping () -> Void, failure: @escaping (Error) -> Void) + func enableAutoupdates(pluginID: String, success: @escaping () -> Void, failure: @escaping (Error) -> Void) + func disableAutoupdates(pluginID: String, success: @escaping () -> Void, failure: @escaping (Error) -> Void) + func activateAndEnableAutoupdates(pluginID: String, success: @escaping () -> Void, failure: @escaping (Error) -> Void) + func install(pluginSlug: String, success: @escaping (PluginState) -> Void, failure: @escaping (Error) -> Void) + func remove(pluginID: String, success: @escaping () -> Void, failure: @escaping (Error) -> Void) +} diff --git a/Modules/Sources/WordPressKit/PluginServiceRemote.swift b/Modules/Sources/WordPressKit/PluginServiceRemote.swift new file mode 100644 index 000000000000..a244e1c3ba02 --- /dev/null +++ b/Modules/Sources/WordPressKit/PluginServiceRemote.swift @@ -0,0 +1,255 @@ +import Foundation +import WordPressKitObjC + +public class PluginServiceRemote: ServiceRemoteWordPressComREST { + public enum ResponseError: Error { + case decodingFailure + case invalidInputError + case unauthorized + case unknownError + } + + public func getFeaturedPlugins(success: @escaping ([PluginDirectoryEntry]) -> Void, failure: @escaping (Error) -> Void) { + let endpoint = "wpcom/v2/plugins/featured" + + wordPressComRESTAPI.get(endpoint, parameters: nil, success: { (responseObject, _) in + guard let response = responseObject as? [[String: AnyObject]] else { + failure(ResponseError.decodingFailure) + return + } + do { + let pluginEntries = try response.map { try PluginDirectoryEntry(responseObject: $0) } + success(pluginEntries) + } catch { + failure(ResponseError.decodingFailure) + } + }, failure: { (error, _) in + WPKitLogError("[PluginServiceRemoteError] Error fetching featured plugins: \(error)") + failure(error) + }) + } + + public func getPlugins(siteID: Int, success: @escaping (SitePlugins) -> Void, failure: @escaping (Error) -> Void) { + let endpoint = "sites/\(siteID)/plugins" + let path = self.path(forEndpoint: endpoint, withVersion: ._1_2) + let parameters = [String: AnyObject]() + + wordPressComRESTAPI.get(path, parameters: parameters, success: { (responseObject, _) in + guard let response = responseObject as? [String: AnyObject] else { + failure(ResponseError.decodingFailure) + return + } + do { + let pluginStates = try self.pluginStates(response: response) + let capabilities = try self.pluginCapabilities(response: response) + success(SitePlugins(plugins: pluginStates, capabilities: capabilities)) + } catch { + failure(self.errorFromResponse(response)) + } + }, failure: { (error, _) in + WPKitLogError("[PluginServiceRemoteError] Error fetching site plugins: \(error)") + failure(error) + }) + } + + public func updatePlugin(pluginID: String, siteID: Int, success: @escaping (PluginState) -> Void, failure: @escaping (Error) -> Void) { + guard let escapedPluginID = encoded(pluginID: pluginID) else { + return + } + let endpoint = "sites/\(siteID)/plugins/\(escapedPluginID)/update" + let path = self.path(forEndpoint: endpoint, withVersion: ._1_2) + let parameters = [String: AnyObject]() + + wordPressComRESTAPI.post( + path, + parameters: parameters, + success: { (responseObject, _) in + guard let response = responseObject as? [String: AnyObject] else { + failure(ResponseError.decodingFailure) + return + } + do { + let pluginState = try self.pluginState(response: response) + success(pluginState) + } catch { + failure(self.errorFromResponse(response)) + } + }, + failure: { (error, _) in + WPKitLogError("[PluginServiceRemoteError] Error updating plugin: \(error)") + failure(error) + }) + } + + public func activatePlugin(pluginID: String, siteID: Int, success: @escaping () -> Void, failure: @escaping (Error) -> Void) { + let parameters = [ + "active": "true" + ] as [String: AnyObject] + modifyPlugin(parameters: parameters, pluginID: pluginID, siteID: siteID, success: success, failure: failure) + } + + public func deactivatePlugin(pluginID: String, siteID: Int, success: @escaping () -> Void, failure: @escaping (Error) -> Void) { + let parameters = [ + "active": "false" + ] as [String: AnyObject] + modifyPlugin(parameters: parameters, pluginID: pluginID, siteID: siteID, success: success, failure: failure) + } + + public func enableAutoupdates(pluginID: String, siteID: Int, success: @escaping () -> Void, failure: @escaping (Error) -> Void) { + let parameters = [ + "autoupdate": "true" + ] as [String: AnyObject] + modifyPlugin(parameters: parameters, pluginID: pluginID, siteID: siteID, success: success, failure: failure) + } + + public func disableAutoupdates(pluginID: String, siteID: Int, success: @escaping () -> Void, failure: @escaping (Error) -> Void) { + let parameters = [ + "autoupdate": "false" + ] as [String: AnyObject] + modifyPlugin(parameters: parameters, pluginID: pluginID, siteID: siteID, success: success, failure: failure) + } + + public func activateAndEnableAutoupdates(pluginID: String, siteID: Int, success: @escaping () -> Void, failure: @escaping (Error) -> Void) { + let parameters = [ + "active": "true", + "autoupdate": "true" + ] as [String: AnyObject] + modifyPlugin(parameters: parameters, pluginID: pluginID, siteID: siteID, success: success, failure: failure) + } + + public func install(pluginSlug: String, siteID: Int, success: @escaping (PluginState) -> Void, failure: @escaping (Error) -> Void) { + let endpoint = "sites/\(siteID)/plugins/\(pluginSlug)/install" + let path = self.path(forEndpoint: endpoint, withVersion: ._1_2) + + wordPressComRESTAPI.post( + path, + parameters: nil, + success: { responseObject, _ in + guard let response = responseObject as? [String: AnyObject] else { + failure(ResponseError.decodingFailure) + return + } + do { + let pluginState = try self.pluginState(response: response) + success(pluginState) + } catch { + failure(self.errorFromResponse(response)) + } + }, failure: { (error, _) in + WPKitLogError("[PluginServiceRemoteError] Error installing plugin: \(error)") + failure(error) + } + ) + } + + public func remove(pluginID: String, siteID: Int, success: @escaping () -> Void, failure: @escaping (Error) -> Void) { + guard let escapedPluginID = encoded(pluginID: pluginID) else { + return + } + let endpoint = "sites/\(siteID)/plugins/\(escapedPluginID)/delete" + let path = self.path(forEndpoint: endpoint, withVersion: ._1_2) + + wordPressComRESTAPI.post( + path, + parameters: nil, + success: { _, _ in + success() + }, failure: { (error, _) in + WPKitLogError("[PluginServiceRemoteError] Error removing plugin: \(error)") + failure(error) + } + ) + } + + private func modifyPlugin(parameters: [String: AnyObject], pluginID: String, siteID: Int, success: @escaping () -> Void, failure: @escaping (Error) -> Void) { + guard let escapedPluginID = encoded(pluginID: pluginID) else { + return + } + let endpoint = "sites/\(siteID)/plugins/\(escapedPluginID)" + let path = self.path(forEndpoint: endpoint, withVersion: ._1_2) + + wordPressComRESTAPI.post( + path, + parameters: parameters, + success: { _, _ in + success() + }, + failure: { (error, _) in + WPKitLogError("[PluginServiceRemoteError] Error modifying plugin: \(error)") + failure(error) + }) + } +} + +internal extension PluginServiceRemote { + func encoded(pluginID: String) -> String? { + let allowedCharacters = CharacterSet.urlPathAllowed.subtracting(CharacterSet(charactersIn: "/")) + guard let escapedPluginID = pluginID.addingPercentEncoding(withAllowedCharacters: allowedCharacters) else { + assertionFailure("Can't escape plugin ID: \(pluginID)") + return nil + } + return escapedPluginID + } + + func pluginStates(response: [String: AnyObject]) throws -> [PluginState] { + guard let plugins = response["plugins"] as? [[String: AnyObject]] else { + throw ResponseError.decodingFailure + } + + return try plugins.map { (plugin) -> PluginState in + return try pluginState(response: plugin) + } + } + + func pluginState(response: [String: AnyObject]) throws -> PluginState { + guard let id = response["name"] as? String, + let slug = response["slug"] as? String, + let active = response["active"] as? Bool, + let autoupdate = response["autoupdate"] as? Bool, + let name = response["display_name"] as? String, + let author = response["author"] as? String else { + throw ResponseError.decodingFailure + } + + let version = (response["version"] as? String)?.nonEmptyString() + let url = (response["plugin_url"] as? String).flatMap(URL.init(string:)) + let availableUpdate = (response["update"] as? [String: String])?["new_version"] + let updateState: PluginState.UpdateState = availableUpdate.map({ .available($0) }) ?? .updated + + let actions = response["action_links"] as? [String: String] + let settingsURL = (actions?["Settings"]).flatMap(URL.init(string:)) + + return PluginState(id: id, + slug: slug, + active: active, + name: name, + author: author, + version: version, + updateState: updateState, + autoupdate: autoupdate, + automanaged: false, + url: url, + settingsURL: settingsURL) + } + + func pluginCapabilities(response: [String: AnyObject]) throws -> SitePluginCapabilities { + guard let capabilities = response["file_mod_capabilities"] as? [String: AnyObject], + let modify = capabilities["modify_files"] as? Bool, + let autoupdate = capabilities["autoupdate_files"] as? Bool else { + throw ResponseError.decodingFailure + } + return SitePluginCapabilities(modify: modify, autoupdate: autoupdate) + } + + func errorFromResponse(_ response: [String: AnyObject]) -> ResponseError { + guard let code = response["error"] as? String else { + return .decodingFailure + } + switch code { + case "unauthorized": + return .unauthorized + default: + return .unknownError + } + } +} diff --git a/Modules/Sources/WordPressKit/PluginState.swift b/Modules/Sources/WordPressKit/PluginState.swift new file mode 100644 index 000000000000..8123998d3aa6 --- /dev/null +++ b/Modules/Sources/WordPressKit/PluginState.swift @@ -0,0 +1,121 @@ +import Foundation + +public struct PluginState: Equatable, Codable { + @frozen public enum UpdateState: Equatable, Codable { + public static func ==(lhs: PluginState.UpdateState, rhs: PluginState.UpdateState) -> Bool { + switch (lhs, rhs) { + case (.updated, .updated): + return true + case (.available(let lhsValue), .available(let rhsValue)): + return lhsValue == rhsValue + case (.updating(let lhsValue), .updating(let rhsValue)): + return lhsValue == rhsValue + default: + return false + } + } + + private enum CodingKeys: String, CodingKey { + case updated + case available + case updating + } + + case updated + case available(String) + case updating(String) + + public func encode(to encoder: Encoder) throws { + var encoder = encoder.container(keyedBy: CodingKeys.self) + + switch self { + case .updated: + try encoder.encode(true, forKey: .updated) + case .available(let value): + try encoder.encode(value, forKey: .available) + case .updating(let value): + try encoder.encode(value, forKey: .updating) + } + } + + public init(from decoder: Decoder) throws { + let decoder = try decoder.container(keyedBy: CodingKeys.self) + + if let _ = try decoder.decodeIfPresent(Bool.self, forKey: .updated) { + self = .updated + return + } + + if let value = try decoder.decodeIfPresent(String.self, forKey: .available) { + self = .available(value) + return + } + + if let value = try decoder.decodeIfPresent(String.self, forKey: .updating) { + self = .updating(value) + return + } + + self = .updated + } + } + + public let id: String + public let slug: String + public var active: Bool + public let name: String + public let author: String + public let version: String? + public var updateState: UpdateState + public var autoupdate: Bool + public var automanaged: Bool + public let url: URL? + public let settingsURL: URL? + + public static func ==(lhs: PluginState, rhs: PluginState) -> Bool { + return lhs.id == rhs.id + && lhs.slug == rhs.slug + && lhs.active == rhs.active + && lhs.name == rhs.name + && lhs.version == rhs.version + && lhs.updateState == rhs.updateState + && lhs.autoupdate == rhs.autoupdate + && lhs.automanaged == rhs.automanaged + && lhs.url == rhs.url + } +} + +public extension PluginState { + var stateDescription: String { + if automanaged { + return NSLocalizedString("Auto-managed on this site", comment: "The plugin can not be manually updated or deactivated") + } + switch (active, autoupdate) { + case (false, false): + return NSLocalizedString("Inactive, Autoupdates off", comment: "The plugin is not active on the site and has not enabled automatic updates") + case (false, true): + return NSLocalizedString("Inactive, Autoupdates on", comment: "The plugin is not active on the site and has enabled automatic updates") + case (true, false): + return NSLocalizedString("Active, Autoupdates off", comment: "The plugin is active on the site and has not enabled automatic updates") + case (true, true): + return NSLocalizedString("Active, Autoupdates on", comment: "The plugin is active on the site and has enabled automatic updates") + } + } + + var homeURL: URL? { + return url + } + + var directoryURL: URL? { + return URL(string: "https://wordpress.org/plugins/\(slug)") + } + + var deactivateAllowed: Bool { + return !isJetpack && !automanaged + } + + var isJetpack: Bool { + return slug == "jetpack" + || slug == "jetpack-dev" + } +} diff --git a/Modules/Sources/WordPressKit/PostServiceRemoteExtended.swift b/Modules/Sources/WordPressKit/PostServiceRemoteExtended.swift new file mode 100644 index 000000000000..700c1b3337ea --- /dev/null +++ b/Modules/Sources/WordPressKit/PostServiceRemoteExtended.swift @@ -0,0 +1,31 @@ +import Foundation + +public protocol PostServiceRemoteExtended: PostServiceRemote { + /// Returns a post with the given ID. + /// + /// - throws: ``PostServiceRemoteError`` or oher underlying errors + /// (see ``WordPressAPIError``) + func post(withID postID: Int) async throws -> RemotePost + + /// Creates a new post with the given parameters. + func createPost(with parameters: RemotePostCreateParameters) async throws -> RemotePost + + /// Performs a partial update to the existing post. + /// + /// - throws: ``PostServiceRemoteError`` or oher underlying errors + /// (see ``WordPressAPIError``) + func patchPost(withID postID: Int, parameters: RemotePostUpdateParameters) async throws -> RemotePost + + /// Permanently deletes a post with the given ID. + /// + /// - throws: ``PostServiceRemoteError`` or oher underlying errors + /// (see ``WordPressAPIError``) + func deletePost(withID postID: Int) async throws +} + +@frozen public enum PostServiceRemoteError: Error { + /// 409 (Conflict) + case conflict + /// 404 (Not Found) + case notFound +} diff --git a/Modules/Sources/WordPressKit/PostServiceRemoteREST+Extended.swift b/Modules/Sources/WordPressKit/PostServiceRemoteREST+Extended.swift new file mode 100644 index 000000000000..315005463ebb --- /dev/null +++ b/Modules/Sources/WordPressKit/PostServiceRemoteREST+Extended.swift @@ -0,0 +1,98 @@ +import Foundation + +extension PostServiceRemoteREST: PostServiceRemoteExtended { + public func post(withID postID: Int) async throws -> RemotePost { + let path = self.path(forEndpoint: "sites/\(siteID)/posts/\(postID)?context=edit", withVersion: ._1_1) + let result = await wordPressComRestApi.perform(.get, URLString: path) + switch result { + case .success(let response): + return try await decodePost(from: response.body) + case .failure(let error): + if case .endpointError(let error) = error, error.apiErrorCode == "unknown_post" { + throw PostServiceRemoteError.notFound + } + throw error + } + } + + public func createPost(with parameters: RemotePostCreateParameters) async throws -> RemotePost { + let path = self.path(forEndpoint: "sites/\(siteID)/posts/new?context=edit", withVersion: ._1_2) + let parameters = try makeParameters(from: RemotePostCreateParametersWordPressComEncoder(parameters: parameters)) + + let response = try await wordPressComRestApi.perform(.post, URLString: path, parameters: parameters).get() + return try await decodePost(from: response.body) + } + + public func patchPost(withID postID: Int, parameters: RemotePostUpdateParameters) async throws -> RemotePost { + let path = self.path(forEndpoint: "sites/\(siteID)/posts/\(postID)?context=edit", withVersion: ._1_2) + let parameters = try makeParameters(from: RemotePostUpdateParametersWordPressComEncoder(parameters: parameters)) + + let result = await wordPressComRestApi.perform(.post, URLString: path, parameters: parameters) + switch result { + case .success(let response): + return try await decodePost(from: response.body) + case .failure(let error): + guard case .endpointError(let error) = error else { + throw error + } + switch error.apiErrorCode ?? "" { + case "unknown_post": throw PostServiceRemoteError.notFound + case "old-revision": throw PostServiceRemoteError.conflict + default: throw error + } + } + } + + public func deletePost(withID postID: Int) async throws { + let path = self.path(forEndpoint: "sites/\(siteID)/posts/\(postID)/delete", withVersion: ._1_1) + let result = await wordPressComRestApi.perform(.post, URLString: path) + switch result { + case .success: + return + case .failure(let error): + guard case .endpointError(let error) = error else { + throw error + } + switch error.apiErrorCode ?? "" { + case "unknown_post": throw PostServiceRemoteError.notFound + default: throw error + } + } + } + + public func createAutosave(forPostID postID: Int, parameters: RemotePostCreateParameters) async throws -> RemotePostAutosaveResponse { + let path = self.path(forEndpoint: "sites/\(siteID)/posts/\(postID)/autosave", withVersion: ._1_1) + let parameters = try makeParameters(from: RemotePostCreateParametersWordPressComEncoder(parameters: parameters)) + let result = await wordPressComRestApi.perform(.post, URLString: path, parameters: parameters, type: RemotePostAutosaveResponse.self) + return try result.get().body + } +} + +public struct RemotePostAutosaveResponse: Decodable { + public let autosaveID: Int + public let previewURL: URL + + enum CodingKeys: String, CodingKey { + case autosaveID = "ID" + case previewURL = "preview_URL" + } +} + +// Decodes the post in the background. +private func decodePost(from object: AnyObject) async throws -> RemotePost { + guard let dictionary = object as? [AnyHashable: Any] else { + throw WordPressAPIError.unparsableResponse(response: nil, body: nil) + } + return PostServiceRemoteREST.remotePost(fromJSONDictionary: dictionary) +} + +private func makeParameters(from value: T) throws -> [String: AnyObject] { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .formatted(.wordPressCom) + let data = try encoder.encode(value) + let object = try JSONSerialization.jsonObject(with: data) + guard let dictionary = object as? [String: AnyObject] else { + throw URLError(.unknown) // This should never happen + } + return dictionary +} diff --git a/Modules/Sources/WordPressKit/PostServiceRemoteREST+Revisions.swift b/Modules/Sources/WordPressKit/PostServiceRemoteREST+Revisions.swift new file mode 100644 index 000000000000..ae08da963b3a --- /dev/null +++ b/Modules/Sources/WordPressKit/PostServiceRemoteREST+Revisions.swift @@ -0,0 +1,91 @@ +import Foundation + +public extension PostServiceRemoteREST { + func getPostRevisions(for siteId: Int, + postId: Int, + success: @escaping ([RemoteRevision]?) -> Void, + failure: @escaping (Error?) -> Void) { + let endpoint = "sites/\(siteId)/post/\(postId)/diffs" + let path = self.path(forEndpoint: endpoint, withVersion: ._1_1) + wordPressComRESTAPI.get(path, + parameters: nil, + success: { (response, _) in + do { + let data = try JSONSerialization.data(withJSONObject: response, options: []) + self.map(from: data) { (revisions, error) in + if let error { + failure(error) + } else { + success(revisions) + } + } + } catch { + failure(error) + } + }, failure: { error, _ in + WPKitLogError("\(error)") + failure(error) + }) + } + + func getPostLatestRevisionID(for postId: NSNumber, success: @escaping (NSNumber?) -> Void, failure: @escaping (Error?) -> Void) { + let endpoint = "sites/\(siteID)/posts/\(postId)" + let path = self.path(forEndpoint: endpoint, withVersion: ._1_1) + wordPressComRESTAPI.get( + path, + parameters: [ + "context": "edit", + "fields": "revisions" + ] as [String: AnyObject], + success: { (response, _) in + let latestRevision: NSNumber? + if let json = response as? [String: Any], + let revisions = json["revisions"] as? NSArray, + let latest = revisions.firstObject as? NSNumber { + latestRevision = latest + } else { + latestRevision = nil + } + success(latestRevision) + }, + failure: { error, _ in + WPKitLogError("\(error)") + failure(error) + } + ) + } +} + +private extension PostServiceRemoteREST { + private typealias JSONRevision = [String: Any] + + private struct RemoteDiffs: Codable { + var diffs: [RemoteDiff] + } + + private func map(from data: Data, _ completion: @escaping ([RemoteRevision]?, Error?) -> Void) { + do { + var revisions: [RemoteRevision] = [] + + let diffs: RemoteDiffs = try decode(data) + let jsonResult = try JSONSerialization.jsonObject(with: data, options: .mutableLeaves) as? JSONRevision + let revisionsDict = jsonResult?["revisions"] as? [String: JSONRevision] + + try revisionsDict?.forEach { (key: String, value: JSONRevision) in + let revisionData = try JSONSerialization.data(withJSONObject: value, options: .prettyPrinted) + var revision: RemoteRevision = try decode(revisionData) + revision.diff = diffs.diffs.first { $0.toRevisionId == Int(key) } + revisions.append(revision) + } + completion(revisions, nil) + } catch { + WPKitLogError("\(error)") + completion(nil, error) + } + } + + private func decode(_ data: Data) throws -> T { + let decoder = JSONDecoder() + return try decoder.decode(T.self, from: data) + } +} diff --git a/Modules/Sources/WordPressKit/PostServiceRemoteREST.swift b/Modules/Sources/WordPressKit/PostServiceRemoteREST.swift new file mode 100644 index 000000000000..7b4fc9eb3916 --- /dev/null +++ b/Modules/Sources/WordPressKit/PostServiceRemoteREST.swift @@ -0,0 +1,56 @@ +import WordPressKitObjC +import NSObject_SafeExpectations + +extension PostServiceRemoteREST { + + /// Requests a list of users that liked the post with the specified ID. + /// + /// Due to the API limitation, up to 90 users will be returned from the endpoint. + /// + /// - Parameters: + /// - postID: The ID for the post. Cannot be nil. + /// - count: Number of records to retrieve. Cannot be nil. If 0, will default to endpoint max. + /// - before: Filter results to Likes before this date/time string. Can be nil. + /// - excludeUserIDs: Array of user IDs to exclude from response. Can be nil. + /// - success: The block that will be executed on success. Can be nil. + /// - failure: The block that will be executed on failure. Can be nil. + @objc(getLikesForPostID:count:before:excludeUserIDs:success:failure:) + public func getLikesForPostID( + _ postID: NSNumber, + count: NSNumber, + before: String?, + excludeUserIDs: [NSNumber]?, + success: (([RemoteLikeUser], NSNumber) -> Void)?, + failure: ((Error?) -> Void)? + ) { + let path = "sites/\(siteID)/posts/\(postID)/likes" + let requestUrl = self.path(forEndpoint: path, withVersion: ._1_2) + let siteID = self.siteID + + // If no count provided, default to endpoint max. + var parameters: [String: Any] = ["number": count == 0 ? 90 : count] + + if let before { + parameters["before"] = before + } + + if let excludeUserIDs { + parameters["exclude"] = excludeUserIDs + } + + wordPressComRESTAPI.get(requestUrl, + parameters: parameters, + success: { (responseObject, httpResponse) in + if let success { + let responseDict = responseObject as? [String: Any] ?? [:] + let jsonUsers = responseDict["likes"] as? [[String: Any]] ?? [] + let users = jsonUsers.map { RemoteLikeUser(dictionary: $0, postID: postID, siteID: siteID) } + let found = responseDict["found"] as? NSNumber ?? 0 + success(users, found) + } + }, failure: { (error, _) in + failure?(error) + }) + } + +} diff --git a/Modules/Sources/WordPressKit/PostServiceRemoteXMLRPC+Extended.swift b/Modules/Sources/WordPressKit/PostServiceRemoteXMLRPC+Extended.swift new file mode 100644 index 000000000000..f4010838b2b6 --- /dev/null +++ b/Modules/Sources/WordPressKit/PostServiceRemoteXMLRPC+Extended.swift @@ -0,0 +1,80 @@ +import Foundation +import wpxmlrpc +import WordPressKitObjC + +extension PostServiceRemoteXMLRPC: PostServiceRemoteExtended { + public func post(withID postID: Int) async throws -> RemotePost { + let parameters = xmlrpcArguments(withExtra: postID) as [AnyObject] + let result = await xmlrpcApi.call(method: "wp.getPost", parameters: parameters) + switch result { + case .success(let response): + return try await decodePost(from: response.body) + case .failure(let error): + if case .endpointError(let error) = error, error.code == 404 { + throw PostServiceRemoteError.notFound + } + throw error + } + } + + public func createPost(with parameters: RemotePostCreateParameters) async throws -> RemotePost { + let dictionary = try makeParameters(from: RemotePostCreateParametersXMLRPCEncoder(parameters: parameters)) + let parameters = xmlrpcArguments(withExtra: dictionary) as [AnyObject] + let response = try await xmlrpcApi.call(method: "wp.newPost", parameters: parameters).get() + guard let postID = (response.body as? NSObject)?.wpkit_numericValue() else { + throw URLError(.unknown) // Should never happen + } + return try await post(withID: postID.intValue) + } + + public func patchPost(withID postID: Int, parameters: RemotePostUpdateParameters) async throws -> RemotePost { + let dictionary = try makeParameters(from: RemotePostUpdateParametersXMLRPCEncoder(parameters: parameters)) + let parameters = xmlrpcArguments(withExtraDefaults: [postID as NSNumber], andExtra: dictionary) as [AnyObject] + let result = await xmlrpcApi.call(method: "wp.editPost", parameters: parameters) + switch result { + case .success: + return try await post(withID: postID) + case .failure(let error): + guard case .endpointError(let error) = error else { + throw error + } + switch error.code ?? 0 { + case 404: throw PostServiceRemoteError.notFound + case 409: throw PostServiceRemoteError.conflict + default: throw error + } + } + } + + public func deletePost(withID postID: Int) async throws { + let parameters = xmlrpcArguments(withExtra: postID) as [AnyObject] + let result = await xmlrpcApi.call(method: "wp.deletePost", parameters: parameters) + switch result { + case .success: + return + case .failure(let error): + if case .endpointError(let error) = error, error.code == 404 { + throw PostServiceRemoteError.notFound + } + throw error + } + } +} + +private func decodePost(from object: AnyObject) async throws -> RemotePost { + guard let dictionary = object as? [AnyHashable: Any] else { + throw WordPressAPIError.unparsableResponse(response: nil, body: nil) + } + return PostServiceRemoteXMLRPC.remotePost(fromXMLRPCDictionary: dictionary) +} + +private func makeParameters(from value: T) throws -> [String: AnyObject] { + let encoder = PropertyListEncoder() + encoder.outputFormat = .xml + let data = try encoder.encode(value) + let object = try PropertyListSerialization.propertyList(from: data, format: nil) + guard let dictionary = object as? [String: AnyObject] else { + throw URLError(.unknown) // This should never happen + } + return dictionary +} diff --git a/Modules/Sources/WordPressKit/ProductServiceRemote.swift b/Modules/Sources/WordPressKit/ProductServiceRemote.swift new file mode 100644 index 000000000000..9c87d7e24ddf --- /dev/null +++ b/Modules/Sources/WordPressKit/ProductServiceRemote.swift @@ -0,0 +1,82 @@ +import Foundation +import WordPressKitObjC + +/// Provides information about available products for user purchases, such as plans, domains, etc. +/// +open class ProductServiceRemote { + public struct Product { + public let id: Int + public let key: String + public let name: String + public let slug: String + public let description: String + public let currencyCode: String? + public let saleCost: Double? + + public func saleCostForDisplay() -> String? { + guard let currencyCode, + let saleCost else { + return nil + } + + let numberFormatter = NumberFormatter() + numberFormatter.numberStyle = .currency + numberFormatter.currencyCode = currencyCode + + return numberFormatter.string(from: NSNumber(value: saleCost)) + } + } + + let serviceRemote: ServiceRemoteWordPressComREST + + public enum GetProductError: Error { + case failedCastingProductsToDictionary(Any) + } + + public init(restAPI: WordPressComRestApi) { + serviceRemote = ServiceRemoteWordPressComREST(wordPressComRestApi: restAPI) + } + + /// Gets a list of available products for purchase. + /// + open func getProducts(completion: @escaping (Result<[Product], Error>) -> Void) { + let path = serviceRemote.path(forEndpoint: "products", withVersion: ._1_1) + + serviceRemote.wordPressComRESTAPI.get( + path, + parameters: [:], + success: { responseProducts, _ in + guard let productsDictionary = responseProducts as? [String: [String: Any]] else { + completion(.failure(GetProductError.failedCastingProductsToDictionary(responseProducts))) + return + } + + let products = productsDictionary.compactMap { (key: String, value: [String: Any]) -> Product? in + guard let productID = value["product_id"] as? Int else { + return nil + } + + let name = (value["product_name"] as? String) ?? "" + let slug = (value["product_slug"] as? String) ?? "" + let description = (value["description"] as? String) ?? "" + let currencyCode = value["currency_code"] as? String + let saleCost = value["sale_cost"] as? Double + + return Product( + id: productID, + key: key, + name: name, + slug: slug, + description: description, + currencyCode: currencyCode, + saleCost: saleCost) + } + + completion(.success(products)) + }, + failure: { error, _ in + completion(.failure(error)) + } + ) + } +} diff --git a/Modules/Sources/WordPressKit/PushAuthenticationServiceRemote.swift b/Modules/Sources/WordPressKit/PushAuthenticationServiceRemote.swift new file mode 100644 index 000000000000..e6cedf91e371 --- /dev/null +++ b/Modules/Sources/WordPressKit/PushAuthenticationServiceRemote.swift @@ -0,0 +1,32 @@ +import Foundation +import WordPressKitObjC + +/// The purpose of this class is to encapsulate all of the interaction with the REST endpoint, +/// required to handle WordPress.com 2FA Code Veritication via Push Notifications +/// +@objc open class PushAuthenticationServiceRemote: ServiceRemoteWordPressComREST { + /// Verifies a WordPress.com Login. + /// + /// - Parameters: + /// - token: The token passed on by WordPress.com's 2FA Push Notification. + /// - success: Closure to be executed on success. Can be nil. + /// - failure: Closure to be executed on failure. Can be nil. + /// + @objc open func authorizeLogin(_ token: String, success: (() -> Void)?, failure: (() -> Void)?) { + let path = "me/two-step/push-authentication" + let requestUrl = self.path(forEndpoint: path, withVersion: ._1_1) + + let parameters = [ + "action": "authorize_login", + "push_token": token + ] + + wordPressComRESTAPI.post(requestUrl, parameters: parameters, + success: { _, _ in + success?() + }, + failure: { _, _ in + failure?() + }) + } +} diff --git a/Modules/Sources/WordPressKit/QRLoginServiceRemote.swift b/Modules/Sources/WordPressKit/QRLoginServiceRemote.swift new file mode 100644 index 000000000000..772dd6c6d7d7 --- /dev/null +++ b/Modules/Sources/WordPressKit/QRLoginServiceRemote.swift @@ -0,0 +1,63 @@ +import Foundation +import WordPressKitObjC + +open class QRLoginServiceRemote: ServiceRemoteWordPressComREST { + /// Validates the incoming QR Login token and retrieves the requesting browser, and location + open func validate(token: String, data: String, success: @escaping (QRLoginValidationResponse) -> Void, failure: @escaping (Error?, QRLoginError?) -> Void) { + let path = self.path(forEndpoint: "auth/qr-code/validate", withVersion: ._2_0) + let parameters = [ "token": token, "data": data ] as [String: AnyObject] + + wordPressComRESTAPI.post(path, parameters: parameters as [String: AnyObject], success: { (response, _) in + do { + let decoder = JSONDecoder.apiDecoder + let data = try JSONSerialization.data(withJSONObject: response, options: []) + let envelope = try decoder.decode(QRLoginValidationResponse.self, from: data) + + success(envelope) + } catch { + failure(nil, .invalidData) + } + }, failure: { (error, response) in + guard let response else { + failure(error, .invalidData) + return + } + + let statusCode = response.statusCode + failure(error, QRLoginError(statusCode: statusCode)) + }) + } + + /// Authenticates the users browser + open func authenticate(token: String, data: String, success: @escaping(Bool) -> Void, failure: @escaping(Error) -> Void) { + let path = self.path(forEndpoint: "auth/qr-code/authenticate", withVersion: ._2_0) + let parameters = [ "token": token, "data": data ] as [String: AnyObject] + + wordPressComRESTAPI.post(path, parameters: parameters, success: { (response, _) in + guard let responseDict = response as? [String: Any], + let authenticated = responseDict["authenticated"] as? Bool else { + success(false) + return + } + + success(authenticated) + }, failure: { (error, _) in + failure(error) + }) + } +} + +@frozen public enum QRLoginError { + case invalidData + case expired + + init(statusCode: Int) { + switch statusCode { + case 401: + self = .expired + + default: + self = .invalidData + } + } +} diff --git a/Modules/Sources/WordPressKit/QRLoginValidationResponse.swift b/Modules/Sources/WordPressKit/QRLoginValidationResponse.swift new file mode 100644 index 000000000000..5670cba3749d --- /dev/null +++ b/Modules/Sources/WordPressKit/QRLoginValidationResponse.swift @@ -0,0 +1,12 @@ +import Foundation + +public struct QRLoginValidationResponse: Decodable { + /// The name of the browser that the user has requested the login from + /// IE: Chrome, Firefox + /// This may be null if the browser could not be determined + public var browser: String? + + /// The City, State the user has requested the login from + /// IE: Columbus, Ohio + public var location: String +} diff --git a/Modules/Sources/WordPressKit/ReaderFeed.swift b/Modules/Sources/WordPressKit/ReaderFeed.swift new file mode 100644 index 000000000000..fb4971f73d72 --- /dev/null +++ b/Modules/Sources/WordPressKit/ReaderFeed.swift @@ -0,0 +1,75 @@ +import Foundation + +/// ReaderFeed +/// Encapsulates details of a single feed returned by the Reader feed search API +/// (read/feed?q=query) +/// +public struct ReaderFeed: Decodable { + public let url: URL + public let title: String + public let feedDescription: String? + public let feedID: String? + public let blogID: String? + public let blavatarURL: URL? + + private enum CodingKeys: String, CodingKey { + case url = "URL" + case title = "title" + case feedID = "feed_ID" + case blogID = "blog_ID" + case meta = "meta" + } + + private enum MetaKeys: CodingKey { + case data + } + + private enum DataKeys: CodingKey { + case site + } + + private enum SiteKeys: CodingKey { + case description + case icon + } + + private enum IconKeys: CodingKey { + case img + } + + public init(from decoder: Decoder) throws { + // We have to manually decode the feed from the JSON, for a couple of reasons: + // - Some feeds have no `icon` dictionary + // - Some feeds have no `data` dictionary + // - We want to decode whatever we can get, and not fail if neither of those exist + let rootContainer = try decoder.container(keyedBy: CodingKeys.self) + + url = try rootContainer.decode(URL.self, forKey: .url) + title = try rootContainer.decode(String.self, forKey: .title) + feedID = try? rootContainer.decode(String.self, forKey: .feedID) + blogID = try? rootContainer.decode(String.self, forKey: .blogID) + + var feedDescription: String? + var blavatarURL: URL? + + do { + let metaContainer = try rootContainer.nestedContainer(keyedBy: MetaKeys.self, forKey: .meta) + let dataContainer = try metaContainer.nestedContainer(keyedBy: DataKeys.self, forKey: .data) + let siteContainer = try dataContainer.nestedContainer(keyedBy: SiteKeys.self, forKey: .site) + feedDescription = try? siteContainer.decode(String.self, forKey: .description) + + let iconContainer = try siteContainer.nestedContainer(keyedBy: IconKeys.self, forKey: .icon) + blavatarURL = try? iconContainer.decode(URL.self, forKey: .img) + } catch { + } + + self.feedDescription = feedDescription + self.blavatarURL = blavatarURL + } +} + +extension ReaderFeed: CustomStringConvertible { + public var description: String { + return "" + } +} diff --git a/Modules/Sources/WordPressKit/ReaderPostServiceRemote+Cards.swift b/Modules/Sources/WordPressKit/ReaderPostServiceRemote+Cards.swift new file mode 100644 index 000000000000..e5c45ef241ef --- /dev/null +++ b/Modules/Sources/WordPressKit/ReaderPostServiceRemote+Cards.swift @@ -0,0 +1,125 @@ +import Foundation +public enum ReaderSortingOption: String, CaseIterable { + case popularity + case date + case noSorting + + var queryValue: String? { + guard self != .noSorting else { + return nil + } + return rawValue + } +} + +public enum ReaderStream: String { + case discover = "discover" + case firstPosts = "first-posts" +} + +extension ReaderPostServiceRemote { + /// Returns a collection of RemoteReaderCard using the tags API + /// a Reader Card can represent an item for the reader feed, such as + /// - Reader Post + /// - Topics you may like + /// - Blogs you may like and so on + /// + /// - Parameter topics: an array of String representing the topics + /// - Parameter page: a String that represents a page handle + /// - Parameter sortingOption: a ReaderSortingOption that represents a sorting option + /// - Parameter success: Called when the request succeeds and the data returned is valid + /// - Parameter failure: Called if the request fails for any reason, or the response data is invalid + public func fetchCards(for topics: [String], + page: String? = nil, + sortingOption: ReaderSortingOption = .noSorting, + refreshCount: Int? = nil, + success: @escaping ([RemoteReaderCard], String?) -> Void, + failure: @escaping (Error) -> Void) { + let path = "read/tags/cards" + guard let requestUrl = cardsEndpoint(with: path, + topics: topics, + page: page, + sortingOption: sortingOption, + refreshCount: refreshCount) else { + return + } + fetch(requestUrl, success: success, failure: failure) + } + + /// Returns a collection of RemoteReaderCard using the discover streams API + /// a Reader Card can represent an item for the reader feed, such as + /// - Reader Post + /// - Topics you may like + /// - Blogs you may like and so on + /// + /// - Parameter stream: The name of the stream. By default, `.discover`. + /// - Parameter topics: an array of String representing the topics + /// - Parameter page: a String that represents a page handle + /// - Parameter sortingOption: a ReaderSortingOption that represents a sorting option + /// - Parameter count: the number of cards to fetch. Warning: This also changes the number of objects returned for recommended sites/tags. + /// - Parameter success: Called when the request succeeds and the data returned is valid + /// - Parameter failure: Called if the request fails for any reason, or the response data is invalid + public func fetchStreamCards(stream: ReaderStream = .discover, + for topics: [String], + page: String? = nil, + sortingOption: ReaderSortingOption = .noSorting, + refreshCount: Int? = nil, + count: Int? = nil, + success: @escaping ([RemoteReaderCard], String?) -> Void, + failure: @escaping (Error) -> Void) { + let path = "read/streams/\(stream.rawValue)" + guard let requestUrl = cardsEndpoint(with: path, + topics: topics, + page: page, + sortingOption: sortingOption, + count: count, + refreshCount: refreshCount) else { + return + } + fetch(requestUrl, success: success, failure: failure) + } + + private func fetch(_ endpoint: String, + success: @escaping ([RemoteReaderCard], String?) -> Void, + failure: @escaping (Error) -> Void) { + Task { @MainActor [wordPressComRestApi] in + await wordPressComRestApi.perform(.get, URLString: endpoint, type: ReaderCardEnvelope.self) + .map { ($0.body.cards, $0.body.nextPageHandle) } + .mapError { error -> Error in error.asNSError() } + .execute(onSuccess: success, onFailure: failure) + } + } + + private func cardsEndpoint(with path: String, + topics: [String], + page: String? = nil, + sortingOption: ReaderSortingOption = .noSorting, + count: Int? = nil, + refreshCount: Int? = nil) -> String? { + var path = URLComponents(string: path) + + path?.queryItems = topics.map { URLQueryItem(name: "tags[]", value: $0) } + + if let page { + path?.queryItems?.append(URLQueryItem(name: "page_handle", value: page)) + } + + if let sortingOption = sortingOption.queryValue { + path?.queryItems?.append(URLQueryItem(name: "sort", value: sortingOption)) + } + + if let count { + path?.queryItems?.append(URLQueryItem(name: "count", value: String(count))) + } + + if let refreshCount { + path?.queryItems?.append(URLQueryItem(name: "refresh", value: String(refreshCount))) + } + + guard let endpoint = path?.string else { + return nil + } + + return self.path(forEndpoint: endpoint, withVersion: ._2_0) + } +} diff --git a/Modules/Sources/WordPressKit/ReaderPostServiceRemote+RelatedPosts.swift b/Modules/Sources/WordPressKit/ReaderPostServiceRemote+RelatedPosts.swift new file mode 100644 index 000000000000..b65851fd9a9b --- /dev/null +++ b/Modules/Sources/WordPressKit/ReaderPostServiceRemote+RelatedPosts.swift @@ -0,0 +1,48 @@ +import Foundation + +extension ReaderPostServiceRemote { + + /// Returns a collection of RemoteReaderSimplePost + /// This method returns related posts for a source post. + /// + /// - Parameter postID: The source post's ID + /// - Parameter siteID: The source site's ID + /// - Parameter count: The number of related posts to retrieve for each post type + /// - Parameter success: Called when the request succeeds and the data returned is valid + /// - Parameter failure: Called if the request fails for any reason, or the response data is invalid + public func fetchRelatedPosts(for postID: Int, + from siteID: Int, + count: Int? = 2, + success: @escaping ([RemoteReaderSimplePost]) -> Void, + failure: @escaping (Error?) -> Void) { + + let endpoint = "read/site/\(siteID)/post/\(postID)/related" + let path = self.path(forEndpoint: endpoint, withVersion: ._1_2) + + let parameters = [ + "size_local": count, + "size_global": count + ] as [String: AnyObject] + + wordPressComRESTAPI.get( + path, + parameters: parameters, + success: { (response, _) in + do { + let decoder = JSONDecoder() + let data = try JSONSerialization.data(withJSONObject: response, options: []) + let envelope = try decoder.decode(RemoteReaderSimplePostEnvelope.self, from: data) + + success(envelope.posts) + } catch { + WPKitLogError("Error parsing the reader related posts response: \(error)") + failure(error) + } + }, + failure: { (error, _) in + WPKitLogError("Error fetching reader related posts: \(error)") + failure(error) + } + ) + } +} diff --git a/Modules/Sources/WordPressKit/ReaderPostServiceRemote+Subscriptions.swift b/Modules/Sources/WordPressKit/ReaderPostServiceRemote+Subscriptions.swift new file mode 100644 index 000000000000..9661cf4794c0 --- /dev/null +++ b/Modules/Sources/WordPressKit/ReaderPostServiceRemote+Subscriptions.swift @@ -0,0 +1,145 @@ +import Foundation + +extension ReaderPostServiceRemote { + + public enum ResponseError: Error { + case decodingFailed + } + + private enum Constants { + static let isSubscribed = "i_subscribe" + static let success = "success" + static let receivesNotifications = "receives_notifications" + + /// Request parameter key used for updating the notification settings of a post subscription. + static let receiveNotificationsRequestKey = "receive_notifications" + } + + /// Fetches the subscription status of the specified post for the current user. + /// + /// - Parameters: + /// - postID: The ID of the post. + /// - siteID: The ID of the site. + /// - success: Success block called on a successful fetch. + /// - failure: Failure block called if there is any error. + @objc open func fetchSubscriptionStatus(for postID: Int, + from siteID: Int, + success: @escaping (Bool) -> Void, + failure: @escaping (Error?) -> Void) { + let path = self.path(forEndpoint: "sites/\(siteID)/posts/\(postID)/subscribers/mine", withVersion: ._1_1) + + wordPressComRESTAPI.get(path, parameters: nil, success: { response, _ in + do { + guard let responseObject = response as? [String: AnyObject], + let isSubscribed = responseObject[Constants.isSubscribed] as? Bool else { + throw ReaderPostServiceRemote.ResponseError.decodingFailed + } + + success(isSubscribed) + } catch { + failure(error) + } + }) { error, _ in + WPKitLogError("Error fetching subscription status: \(error)") + failure(error) + } + } + + /// Mark a post as subscribed by the user. + /// + /// - Parameters: + /// - postID: The ID of the post. + /// - siteID: The ID of the site. + /// - success: Success block called on a successful fetch. + /// - failure: Failure block called if there is any error. + @objc open func subscribeToPost(with postID: Int, + for siteID: Int, + success: @escaping (Bool) -> Void, + failure: @escaping (Error?) -> Void) { + let path = self.path(forEndpoint: "sites/\(siteID)/posts/\(postID)/subscribers/new", withVersion: ._1_1) + + wordPressComRESTAPI.post(path, parameters: nil, success: { response, _ in + do { + guard let responseObject = response as? [String: AnyObject], + let subscribed = responseObject[Constants.success] as? Bool else { + throw ReaderPostServiceRemote.ResponseError.decodingFailed + } + + success(subscribed) + } catch { + failure(error) + } + }) { error, _ in + WPKitLogError("Error subscribing to comments in the post: \(error)") + failure(error) + } + } + + /// Mark a post as unsubscribed by the user. + /// + /// - Parameters: + /// - postID: The ID of the post. + /// - siteID: The ID of the site. + /// - success: Success block called on a successful fetch. + /// - failure: Failure block called if there is any error. + @objc open func unsubscribeFromPost(with postID: Int, + for siteID: Int, + success: @escaping (Bool) -> Void, + failure: @escaping (Error) -> Void) { + let path = self.path(forEndpoint: "sites/\(siteID)/posts/\(postID)/subscribers/mine/delete", withVersion: ._1_1) + + wordPressComRESTAPI.post(path, parameters: nil, success: { response, _ in + do { + guard let responseObject = response as? [String: AnyObject], + let unsubscribed = responseObject[Constants.success] as? Bool else { + throw ReaderPostServiceRemote.ResponseError.decodingFailed + } + + success(unsubscribed) + } catch { + failure(error) + } + }) { error, _ in + WPKitLogError("Error unsubscribing from comments in the post: \(error)") + failure(error) + } + } + + /// Updates the notification settings for a post subscription. + /// + /// When the `receivesNotification` parameter is set to `true`, the subscriber will receive a notification whenever there is a new comment on the + /// subscribed post. Note that the subscriber will still receive emails. On the contrary, when the `receivesNotification` parameter is set to `false`, + /// subscriber will no longer receive notifications for new comments, but will still receive emails. To fully unsubscribe, refer to the + /// `unsubscribeFromPost` method. + /// + /// - Parameters: + /// - postID: The ID of the post. + /// - siteID: The ID of the site. + /// - receiveNotifications: When the value is true, subscriber will also receive a push notification for new comments on the subscribed post. + /// - success: Closure called when the request has succeeded. + /// - failure: Closure called when the request has failed. + @objc open func updateNotificationSettingsForPost(with postID: Int, + siteID: Int, + receiveNotifications: Bool, + success: @escaping () -> Void, + failure: @escaping (Error?) -> Void) { + let path = self.path(forEndpoint: "sites/\(siteID)/posts/\(postID)/subscribers/mine/update", withVersion: ._1_1) + + wordPressComRESTAPI.post(path, + parameters: [Constants.receiveNotificationsRequestKey: receiveNotifications] as [String: AnyObject], + success: { response, _ in + guard let responseObject = response as? [String: AnyObject], + let remoteReceivesNotifications = responseObject[Constants.receivesNotifications] as? Bool, + remoteReceivesNotifications == receiveNotifications else { + failure(ResponseError.decodingFailed) + return + } + + success() + + }, failure: { error, _ in + WPKitLogError("Error updating post subscription: \(error)") + failure(error) + }) + } +} diff --git a/Modules/Sources/WordPressKit/ReaderPostServiceRemote+V2.swift b/Modules/Sources/WordPressKit/ReaderPostServiceRemote+V2.swift new file mode 100644 index 000000000000..f402ab6411bb --- /dev/null +++ b/Modules/Sources/WordPressKit/ReaderPostServiceRemote+V2.swift @@ -0,0 +1,131 @@ +import Foundation + +extension ReaderPostServiceRemote { + /// Returns a collection of RemoteReaderPost + /// This method returns the best available content for the given topics. + /// + /// - Parameter topics: an array of String representing the topics + /// - Parameter page: a String that represents a page handle + /// - Parameter success: Called when the request succeeds and the data returned is valid + /// - Parameter failure: Called if the request fails for any reason, or the response data is invalid + public func fetchPosts(for topics: [String], + page: String? = nil, + refreshCount: Int? = nil, + success: @escaping ([RemoteReaderPost], String?) -> Void, + failure: @escaping (Error) -> Void) { + guard let requestUrl = postsEndpoint(for: topics, page: page) else { + return + } + + wordPressComRESTAPI.get(requestUrl, + parameters: nil, + success: { response, _ in + let responseDict = response as? [String: Any] + let nextPageHandle = responseDict?["next_page_handle"] as? String + let postsDictionary = responseDict?["posts"] as? [[String: Any]] + let posts = postsDictionary?.compactMap { RemoteReaderPost(dictionary: $0) } ?? [] + success(posts, nextPageHandle) + }, failure: { error, _ in + WPKitLogError("Error fetching reader posts: \(error)") + failure(error) + }) + } + + private func postsEndpoint(for topics: [String], page: String? = nil) -> String? { + var path = URLComponents(string: "read/tags/posts") + + path?.queryItems = topics.map { URLQueryItem(name: "tags[]", value: $0) } + + if let page { + path?.queryItems?.append(URLQueryItem(name: "page_handle", value: page)) + } + + guard let endpoint = path?.string else { + return nil + } + + return self.path(forEndpoint: endpoint, withVersion: ._2_0) + } + + /// Sets the `is_seen` status for a given feed post. + /// + /// - Parameter seen: the post is to be marked seen or not (unseen) + /// - Parameter feedID: feedID of the ReaderPost + /// - Parameter feedItemID: feedItemID of the ReaderPost + /// - Parameter success: Called when the request succeeds + /// - Parameter failure: Called when the request fails + @objc + public func markFeedPostSeen(seen: Bool, + feedID: NSNumber, + feedItemID: NSNumber, + success: @escaping (() -> Void), + failure: @escaping ((Error) -> Void)) { + let endpoint = seen ? SeenEndpoints.feedSeen : SeenEndpoints.feedUnseen + + let params = [ + "feed_id": feedID, + "feed_item_ids": [feedItemID], + "source": "reader-ios" + ] as [String: AnyObject] + + updateSeenStatus(endpoint: endpoint, params: params, success: success, failure: failure) + } + + /// Sets the `is_seen` status for a given blog post. + /// + /// - Parameter seen: the post is to be marked seen or not (unseen) + /// - Parameter blogID: blogID of the ReaderPost + /// - Parameter postID: postID of the ReaderPost + /// - Parameter success: Called when the request succeeds + /// - Parameter failure: Called when the request fails + @objc + public func markBlogPostSeen(seen: Bool, + blogID: NSNumber, + postID: NSNumber, + success: @escaping (() -> Void), + failure: @escaping ((Error) -> Void)) { + let endpoint = seen ? SeenEndpoints.blogSeen : SeenEndpoints.blogUnseen + + let params = [ + "blog_id": blogID, + "post_ids": [postID], + "source": "reader-ios" + ] as [String: AnyObject] + + updateSeenStatus(endpoint: endpoint, params: params, success: success, failure: failure) + } + + private func updateSeenStatus(endpoint: String, + params: [String: AnyObject], + success: @escaping (() -> Void), + failure: @escaping ((Error) -> Void)) { + + let path = self.path(forEndpoint: endpoint, withVersion: ._2_0) + + wordPressComRESTAPI.post(path, parameters: params, success: { (responseObject, _) in + guard let response = responseObject as? [String: AnyObject], + let status = response["status"] as? Bool, + status == true else { + failure(MarkSeenError.failed) + return + } + success() + }, failure: { (error, _) in + failure(error) + }) + } + + private struct SeenEndpoints { + // Creates a new `seen` entry (i.e. mark as seen) + static let feedSeen = "seen-posts/seen/new" + static let blogSeen = "seen-posts/seen/blog/new" + // Removes the `seen` entry (i.e. mark as unseen) + static let feedUnseen = "seen-posts/seen/delete" + static let blogUnseen = "seen-posts/seen/blog/delete" + } + + private enum MarkSeenError: Error { + case failed + } + +} diff --git a/Modules/Sources/WordPressKit/ReaderServiceDeliveryFrequency.swift b/Modules/Sources/WordPressKit/ReaderServiceDeliveryFrequency.swift new file mode 100644 index 000000000000..b7d060a98288 --- /dev/null +++ b/Modules/Sources/WordPressKit/ReaderServiceDeliveryFrequency.swift @@ -0,0 +1,12 @@ +import Foundation + +/// Delivery frequency values for email notifications +/// +/// - daily: daily frequency +/// - instantly: instantly frequency +/// - weekly: weekly frequency +@frozen public enum ReaderServiceDeliveryFrequency: String { + case daily + case instantly + case weekly +} diff --git a/Modules/Sources/WordPressKit/ReaderSiteSearchServiceRemote.swift b/Modules/Sources/WordPressKit/ReaderSiteSearchServiceRemote.swift new file mode 100644 index 000000000000..03d4739cffef --- /dev/null +++ b/Modules/Sources/WordPressKit/ReaderSiteSearchServiceRemote.swift @@ -0,0 +1,84 @@ +import Foundation + +public class ReaderSiteSearchServiceRemote: ServiceRemoteWordPressComREST { + + public enum ResponseError: Error { + case decodingFailure + } + + /// Searches Reader for sites matching the specified query. + /// + /// - Parameters: + /// - query: A search string to match + /// - offset: The first N results to skip when returning results. + /// - count: Number of objects to retrieve. + /// - success: Closure to be executed on success. Is passed an array of + /// ReaderFeeds, a boolean indicating if there's more results + /// to fetch, and a total feed count. + /// - failure: Closure to be executed on error. + /// + public func performSearch(_ query: String, + offset: Int = 0, + count: Int, + success: @escaping (_ results: [ReaderFeed], _ hasMore: Bool, _ feedCount: Int) -> Void, + failure: @escaping (Error) -> Void) { + let endpoint = "read/feed" + let path = self.path(forEndpoint: endpoint, withVersion: ._1_1) + let parameters: [String: AnyObject] = [ + "number": count as AnyObject, + "offset": offset as AnyObject, + "exclude_followed": false as AnyObject, + "sort": "relevance" as AnyObject, + "meta": "site" as AnyObject, + "q": query as AnyObject + ] + + wordPressComRESTAPI.get(path, + parameters: parameters, + success: { response, _ in + do { + let (results, total) = try self.mapSearchResponse(response) + let hasMore = total > (offset + count) + success(results, hasMore, total) + } catch { + failure(error) + } + }, failure: { error, _ in + WPKitLogError("\(error)") + failure(error) + }) + } +} + +private extension ReaderSiteSearchServiceRemote { + + func mapSearchResponse(_ response: Any) throws -> ([ReaderFeed], Int) { + do { + let decoder = JSONDecoder() + let data = try JSONSerialization.data(withJSONObject: response, options: []) + let envelope = try decoder.decode(ReaderFeedEnvelope.self, from: data) + + // Filter out any feeds that don't have either a feed ID or a blog ID + let feeds = envelope.feeds.filter({ $0.feedID != nil || $0.blogID != nil }) + return (feeds, envelope.total) + } catch { + WPKitLogError("\(error)") + WPKitLogDebug("Full response: \(response)") + throw ReaderSiteSearchServiceRemote.ResponseError.decodingFailure + } + } +} + +/// ReaderFeedEnvelope +/// The Reader feed search endpoint returns feeds in a key named `feeds` key. +/// This entity allows us to do parse that and the total feed count using JSONDecoder. +/// +private struct ReaderFeedEnvelope: Decodable { + let feeds: [ReaderFeed] + let total: Int + + private enum CodingKeys: String, CodingKey { + case feeds = "feeds" + case total = "total" + } +} diff --git a/Modules/Sources/WordPressKit/ReaderTopicServiceError.swift b/Modules/Sources/WordPressKit/ReaderTopicServiceError.swift new file mode 100644 index 000000000000..41431c39c495 --- /dev/null +++ b/Modules/Sources/WordPressKit/ReaderTopicServiceError.swift @@ -0,0 +1,24 @@ +import Foundation + +public enum ReaderTopicServiceError: Error { + case invalidId + case topicNotfound(id: Int) + case remoteResponse(message: String?, url: String) + + public var description: String { + switch self { + case .invalidId: + return "Invalid id: an id must be valid or not nil" + + case .topicNotfound(let id): + let localizedString = NSLocalizedString("Topic not found for id:", + comment: "Used when a Reader Topic is not found for a specific id") + return localizedString + " \(id)" + + case .remoteResponse(let message, let url): + let localizedString = NSLocalizedString("An error occurred while processing your request: ", + comment: "Used when a remote response doesn't have a specific message for a specific request") + return message ?? localizedString + " \(url)" + } + } +} diff --git a/Modules/Sources/WordPressKit/ReaderTopicServiceRemote+Interests.swift b/Modules/Sources/WordPressKit/ReaderTopicServiceRemote+Interests.swift new file mode 100644 index 000000000000..103a4fc61a60 --- /dev/null +++ b/Modules/Sources/WordPressKit/ReaderTopicServiceRemote+Interests.swift @@ -0,0 +1,55 @@ +import Foundation + +extension ReaderTopicServiceRemote { + /// Returns a collection of RemoteReaderInterest + /// - Parameters: + /// - Parameter success: Called when the request succeeds and the data returned is valid + /// - Parameter failure: Called if the request fails for any reason, or the response data is invalid + public func fetchInterests(_ success: @escaping ([RemoteReaderInterest]) -> Void, + failure: @escaping (Error) -> Void) { + let path = self.path(forEndpoint: "read/interests", withVersion: ._2_0) + + wordPressComRESTAPI.get(path, + parameters: nil, + success: { response, _ in + do { + let decoder = JSONDecoder() + let data = try JSONSerialization.data(withJSONObject: response, options: []) + let envelope = try decoder.decode(ReaderInterestEnvelope.self, from: data) + + success(envelope.interests) + } catch { + WPKitLogError("Error parsing the reader interests response: \(error)") + failure(error) + } + }, failure: { error, _ in + WPKitLogError("Error fetching reader interests: \(error)") + + failure(error) + }) + } + + /// Follows multiple tags/interests at once using their slugs + public func followInterests(withSlugs: [String], + success: @escaping () -> Void, + failure: @escaping (Error) -> Void) { + let path = self.path(forEndpoint: "read/tags/mine/new", withVersion: ._1_2) + let parameters = ["tags": withSlugs] as [String: AnyObject] + + wordPressComRESTAPI.post(path, parameters: parameters, success: { _, _ in + success() + }) { error, _ in + WPKitLogError("Error fetching reader interests: \(error)") + + failure(error) + } + } + + /// Returns an API path for the given tag/topic/interest slug + /// - Returns: https://_api_/read/tags/_slug_/posts + public func pathForTopic(slug: String) -> String { + let endpoint = path(forEndpoint: "read/tags/\(slug)/posts", withVersion: ._1_2) + + return wordPressComRestApi.baseURL.appendingPathComponent(endpoint).absoluteString + } +} diff --git a/Modules/Sources/WordPressKit/ReaderTopicServiceRemote+Subscription.swift b/Modules/Sources/WordPressKit/ReaderTopicServiceRemote+Subscription.swift new file mode 100644 index 000000000000..4e597b0dcf21 --- /dev/null +++ b/Modules/Sources/WordPressKit/ReaderTopicServiceRemote+Subscription.swift @@ -0,0 +1,96 @@ +import Foundation + +extension ReaderTopicServiceRemote { + private struct Delivery { + static let frequency = "delivery_frequency" + } + + /// Subscribe action for site notifications + /// + /// - Parameters: + /// - siteId: A site id + /// - success: Success block + /// - failure: Failure block + @nonobjc public func subscribeSiteNotifications(with siteId: Int, _ success: @escaping () -> Void, _ failure: @escaping (ReaderTopicServiceError?) -> Void) { + POST(with: .notifications(siteId: siteId, action: .subscribe), success: success, failure: failure) + } + + /// Unsubscribe action for site notifications + /// + /// - Parameters: + /// - siteId: A site id + /// - success: Success block + /// - failure: Failure block + @nonobjc public func unsubscribeSiteNotifications(with siteId: Int, _ success: @escaping () -> Void, _ failure: @escaping (ReaderTopicServiceError?) -> Void) { + POST(with: .notifications(siteId: siteId, action: .unsubscribe), success: success, failure: failure) + } + + /// Subscribe action for site comments + /// + /// - Parameters: + /// - siteId: A site id + /// - success: Success block + /// - failure: Failure block + @nonobjc public func subscribeSiteComments(with siteId: Int, _ success: @escaping () -> Void, _ failure: @escaping (ReaderTopicServiceError?) -> Void) { + POST(with: .comments(siteId: siteId, action: .subscribe), success: success, failure: failure) + } + + /// Unubscribe action for site comments + /// + /// - Parameters: + /// - siteId: A site id + /// - success: Success block + /// - failure: Failure block + @nonobjc public func unsubscribeSiteComments(with siteId: Int, _ success: @escaping () -> Void, _ failure: @escaping (ReaderTopicServiceError?) -> Void) { + POST(with: .comments(siteId: siteId, action: .unsubscribe), success: success, failure: failure) + } + + /// Subscribe action for post emails + /// + /// - Parameters: + /// - siteId: A site id + /// - success: Success block + /// - failure: Failure block + @nonobjc public func subscribePostsEmail(with siteId: Int, _ success: @escaping () -> Void, _ failure: @escaping (ReaderTopicServiceError?) -> Void) { + POST(with: .postsEmail(siteId: siteId, action: .subscribe), success: success, failure: failure) + } + + /// Unsubscribe action for post emails + /// + /// - Parameters: + /// - siteId: A site id + /// - success: Success block + /// - failure: Failure block + @nonobjc public func unsubscribePostsEmail(with siteId: Int, _ success: @escaping () -> Void, _ failure: @escaping (ReaderTopicServiceError?) -> Void) { + POST(with: .postsEmail(siteId: siteId, action: .unsubscribe), success: success, failure: failure) + } + + /// Update action for posts email + /// + /// - Parameters: + /// - siteId: A site id + /// - frequency: The frequency value + /// - success: Success block + /// - failure: Failure block + @nonobjc public func updateFrequencyPostsEmail(with siteId: Int, frequency: ReaderServiceDeliveryFrequency, _ success: @escaping () -> Void, _ failure: @escaping (ReaderTopicServiceError?) -> Void) { + let parameters = [Delivery.frequency: NSString(string: frequency.rawValue)] + POST(with: .postsEmail(siteId: siteId, action: .update), parameters: parameters, success: success, failure: failure) + } + + // MARK: Private methods + + private func POST(with request: ReaderTopicServiceSubscriptionsRequest, parameters: [String: AnyObject]? = nil, success: @escaping () -> Void, failure: @escaping (ReaderTopicServiceError?) -> Void) { + let urlRequest = path(forEndpoint: request.path, withVersion: request.apiVersion) + + WPKitLogInfo("URL: \(urlRequest)") + + wordPressComRESTAPI.post(urlRequest, parameters: parameters, success: { (_, response) in + WPKitLogInfo("Success \(response?.url?.absoluteString ?? "unknown url")") + success() + }) { (error, response) in + WPKitLogError("Error: \(error.localizedDescription)") + let urlAbsoluteString = response?.url?.absoluteString ?? NSLocalizedString("unknown url", comment: "Used when the response doesn't have a valid url to display") + failure(ReaderTopicServiceError.remoteResponse(message: error.localizedDescription, url: urlAbsoluteString)) + } + } +} diff --git a/Modules/Sources/WordPressKit/RemoteBlockEditorSettings.swift b/Modules/Sources/WordPressKit/RemoteBlockEditorSettings.swift new file mode 100644 index 000000000000..cd569cf27f9f --- /dev/null +++ b/Modules/Sources/WordPressKit/RemoteBlockEditorSettings.swift @@ -0,0 +1,82 @@ +import Foundation + +public class RemoteBlockEditorSettings: Codable { + enum CodingKeys: String, CodingKey { + case isFSETheme = "__unstableIsBlockBasedTheme" + case galleryWithImageBlocks = "__unstableGalleryWithImageBlocks" + case quoteBlockV2 = "__experimentalEnableQuoteBlockV2" + case listBlockV2 = "__experimentalEnableListBlockV2" + case rawStyles = "__experimentalStyles" + case rawFeatures = "__experimentalFeatures" + case colors + case gradients + } + + public let isFSETheme: Bool + public let galleryWithImageBlocks: Bool + public let quoteBlockV2: Bool + public let listBlockV2: Bool + public let rawStyles: String? + public let rawFeatures: String? + public let colors: [[String: String]]? + public let gradients: [[String: String]]? + + public lazy var checksum: String = { + return ChecksumUtil.checksum(from: self) + }() + + private static func parseToString(_ container: KeyedDecodingContainer, _ key: CodingKeys) -> String? { + // Swift cuurently doesn't support type conversions from Dictionaries to strings while decoding. So we need to + // parse the reponse then convert it to a string. + guard + let json = try? container.decode([String: Any].self, forKey: key), + let data = try? JSONSerialization.data(withJSONObject: json, options: [.sortedKeys]) + else { + return nil + } + return String(data: data, encoding: .utf8) + } + + required public init(from decoder: Decoder) throws { + let map = try decoder.container(keyedBy: CodingKeys.self) + self.isFSETheme = (try? map.decode(Bool.self, forKey: .isFSETheme)) ?? false + self.galleryWithImageBlocks = (try? map.decode(Bool.self, forKey: .galleryWithImageBlocks)) ?? false + self.quoteBlockV2 = (try? map.decode(Bool.self, forKey: .quoteBlockV2)) ?? false + self.listBlockV2 = (try? map.decode(Bool.self, forKey: .listBlockV2)) ?? false + self.rawStyles = RemoteBlockEditorSettings.parseToString(map, .rawStyles) + self.rawFeatures = RemoteBlockEditorSettings.parseToString(map, .rawFeatures) + self.colors = try? map.decode([[String: String]].self, forKey: .colors) + self.gradients = try? map.decode([[String: String]].self, forKey: .gradients) + } +} + +// MARK: EditorTheme +public class RemoteEditorTheme: Codable { + enum CodingKeys: String, CodingKey { + case themeSupport = "theme_supports" + } + + public let themeSupport: RemoteEditorThemeSupport? + public lazy var checksum: String = { + return ChecksumUtil.checksum(from: themeSupport) + }() +} + +public struct RemoteEditorThemeSupport: Codable { + enum CodingKeys: String, CodingKey { + case colors = "editor-color-palette" + case gradients = "editor-gradient-presets" + case blockTemplates = "block-templates" + } + + public let colors: [[String: String]]? + public let gradients: [[String: String]]? + public let blockTemplates: Bool + + public init(from decoder: Decoder) throws { + let map = try decoder.container(keyedBy: CodingKeys.self) + self.colors = try? map.decode([[String: String]].self, forKey: .colors) + self.gradients = try? map.decode([[String: String]].self, forKey: .gradients) + self.blockTemplates = (try? map.decode(Bool.self, forKey: .blockTemplates)) ?? false + } +} diff --git a/Modules/Sources/WordPressKit/RemoteBlogJetpackModulesSettings.swift b/Modules/Sources/WordPressKit/RemoteBlogJetpackModulesSettings.swift new file mode 100644 index 000000000000..d1af811a9432 --- /dev/null +++ b/Modules/Sources/WordPressKit/RemoteBlogJetpackModulesSettings.swift @@ -0,0 +1,13 @@ +import Foundation + +/// This struct encapsulates the *remote* Jetpack modules settings available for a Blog entity +/// +public struct RemoteBlogJetpackModulesSettings { + /// Indicates whether the Jetpack site serves images from our server. + /// + public let serveImagesFromOurServers: Bool + + public init(serveImagesFromOurServers: Bool) { + self.serveImagesFromOurServers = serveImagesFromOurServers + } +} diff --git a/Modules/Sources/WordPressKit/RemoteBlogJetpackMonitorSettings.swift b/Modules/Sources/WordPressKit/RemoteBlogJetpackMonitorSettings.swift new file mode 100644 index 000000000000..3d822a8b094d --- /dev/null +++ b/Modules/Sources/WordPressKit/RemoteBlogJetpackMonitorSettings.swift @@ -0,0 +1,21 @@ +import Foundation + +/// This struct encapsulates the *remote* Jetpack monitor settings available for a Blog entity +/// +public struct RemoteBlogJetpackMonitorSettings { + + /// Indicates whether the Jetpack site's monitor notifications should be sent by email + /// + public let monitorEmailNotifications: Bool + + /// Indicates whether the Jetpack site's monitor notifications should be sent by push notifications + /// + public let monitorPushNotifications: Bool + + public init(monitorEmailNotifications: Bool, + monitorPushNotifications: Bool) { + self.monitorEmailNotifications = monitorEmailNotifications + self.monitorPushNotifications = monitorPushNotifications + } + +} diff --git a/Modules/Sources/WordPressKit/RemoteBlogJetpackSettings.swift b/Modules/Sources/WordPressKit/RemoteBlogJetpackSettings.swift new file mode 100644 index 000000000000..75c9dc4c0477 --- /dev/null +++ b/Modules/Sources/WordPressKit/RemoteBlogJetpackSettings.swift @@ -0,0 +1,44 @@ +import Foundation + +/// This struct encapsulates the *remote* Jetpack settings available for a Blog entity +/// +public struct RemoteBlogJetpackSettings { + + /// Indicates whether the Jetpack site's monitor is on or off + /// + public let monitorEnabled: Bool + + /// Indicates whether Jetpack will block malicious login attemps + /// + public let blockMaliciousLoginAttempts: Bool + + /// List of IP addresses that will never be blocked for logins by Jetpack + /// + public let loginAllowListedIPAddresses: Set + + /// Indicates whether WordPress.com SSO is enabled for the Jetpack site + /// + public let ssoEnabled: Bool + + /// Indicates whether SSO will try to match accounts by email address + /// + public let ssoMatchAccountsByEmail: Bool + + /// Indicates whether to force or not two-step authentication when users log in via WordPress.com + /// + public let ssoRequireTwoStepAuthentication: Bool + + public init(monitorEnabled: Bool, + blockMaliciousLoginAttempts: Bool, + loginAllowListedIPAddresses: Set, + ssoEnabled: Bool, + ssoMatchAccountsByEmail: Bool, + ssoRequireTwoStepAuthentication: Bool) { + self.monitorEnabled = monitorEnabled + self.blockMaliciousLoginAttempts = blockMaliciousLoginAttempts + self.loginAllowListedIPAddresses = loginAllowListedIPAddresses + self.ssoEnabled = ssoEnabled + self.ssoMatchAccountsByEmail = ssoMatchAccountsByEmail + self.ssoRequireTwoStepAuthentication = ssoRequireTwoStepAuthentication + } +} diff --git a/Modules/Sources/WordPressKit/RemoteBloggingPrompt.swift b/Modules/Sources/WordPressKit/RemoteBloggingPrompt.swift new file mode 100644 index 000000000000..e5ef7ad797e3 --- /dev/null +++ b/Modules/Sources/WordPressKit/RemoteBloggingPrompt.swift @@ -0,0 +1,60 @@ +import Foundation + +public struct RemoteBloggingPrompt { + public var promptID: Int + public var text: String + public var title: String + public var content: String + public var attribution: String + public var date: Date + public var answered: Bool + public var answeredUsersCount: Int + public var answeredUserAvatarURLs: [URL] +} + +// MARK: - Decodable + +extension RemoteBloggingPrompt: Decodable { + enum CodingKeys: String, CodingKey { + case id + case text + case title + case content + case attribution + case date + case answered + case answeredUsersCount = "answered_users_count" + case answeredUserAvatarURLs = "answered_users_sample" + } + + /// Used to format the fetched object's date string to a date. + private static var dateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.locale = .init(identifier: "en_US_POSIX") + formatter.timeZone = .init(secondsFromGMT: 0) + formatter.dateFormat = "yyyy-MM-dd" + return formatter + }() + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + self.promptID = try container.decode(Int.self, forKey: .id) + self.text = try container.decode(String.self, forKey: .text) + self.title = try container.decode(String.self, forKey: .title) + self.content = try container.decode(String.self, forKey: .content) + self.attribution = try container.decode(String.self, forKey: .attribution) + self.answered = try container.decode(Bool.self, forKey: .answered) + self.date = Self.dateFormatter.date(from: try container.decode(String.self, forKey: .date)) ?? Date() + self.answeredUsersCount = try container.decode(Int.self, forKey: .answeredUsersCount) + + let userAvatars = try container.decode([UserAvatar].self, forKey: .answeredUserAvatarURLs) + self.answeredUserAvatarURLs = userAvatars.compactMap { URL(string: $0.avatar) } + } + + /// meta structure to simplify decoding logic for user avatar objects. + /// this is intended to be private. + private struct UserAvatar: Codable { + var avatar: String + } +} diff --git a/Modules/Sources/WordPressKit/RemoteBloggingPromptsSettings.swift b/Modules/Sources/WordPressKit/RemoteBloggingPromptsSettings.swift new file mode 100644 index 000000000000..42cd18ece0c2 --- /dev/null +++ b/Modules/Sources/WordPressKit/RemoteBloggingPromptsSettings.swift @@ -0,0 +1,52 @@ +import Foundation +public struct RemoteBloggingPromptsSettings: Codable { + public var promptCardEnabled: Bool + public var promptRemindersEnabled: Bool + public var reminderDays: ReminderDays + public var reminderTime: String + public var isPotentialBloggingSite: Bool + + public struct ReminderDays: Codable { + public var monday: Bool + public var tuesday: Bool + public var wednesday: Bool + public var thursday: Bool + public var friday: Bool + public var saturday: Bool + public var sunday: Bool + + public init(monday: Bool, tuesday: Bool, wednesday: Bool, thursday: Bool, friday: Bool, saturday: Bool, sunday: Bool) { + self.monday = monday + self.tuesday = tuesday + self.wednesday = wednesday + self.thursday = thursday + self.friday = friday + self.saturday = saturday + self.sunday = sunday + } + } + + private enum CodingKeys: String, CodingKey { + case promptCardEnabled = "prompts_card_opted_in" + case promptRemindersEnabled = "prompts_reminders_opted_in" + case reminderDays = "reminders_days" + case reminderTime = "reminders_time" + case isPotentialBloggingSite = "is_potential_blogging_site" + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(promptRemindersEnabled, forKey: .promptRemindersEnabled) + try container.encode(reminderDays, forKey: .reminderDays) + try container.encode(reminderTime, forKey: .reminderTime) + } + + public init(promptCardEnabled: Bool = false, promptRemindersEnabled: Bool, reminderDays: RemoteBloggingPromptsSettings.ReminderDays, reminderTime: String, isPotentialBloggingSite: Bool = false) { + self.promptCardEnabled = promptCardEnabled + self.promptRemindersEnabled = promptRemindersEnabled + self.reminderDays = reminderDays + self.reminderTime = reminderTime + self.isPotentialBloggingSite = isPotentialBloggingSite + } + +} diff --git a/Modules/Sources/WordPressKit/RemoteCommentV2.swift b/Modules/Sources/WordPressKit/RemoteCommentV2.swift new file mode 100644 index 000000000000..d993f05f192b --- /dev/null +++ b/Modules/Sources/WordPressKit/RemoteCommentV2.swift @@ -0,0 +1,96 @@ +import Foundation +/// Captures the JSON structure for Comments returned from API v2 endpoint. +public struct RemoteCommentV2 { + public var commentID: Int + public var postID: Int + public var parentID: Int = 0 + public var authorID: Int + public var authorName: String? + public var authorEmail: String? // only available in edit context + public var authorURL: String? + public var authorIP: String? // only available in edit context + public var authorUserAgent: String? // only available in edit context + public var authorAvatarURL: String? + public var date: Date + public var content: String + public var rawContent: String? // only available in edit context + public var link: String + public var status: String + public var type: String +} + +// MARK: - Decodable + +extension RemoteCommentV2: Decodable { + enum CodingKeys: String, CodingKey { + case id + case post + case parent + case author + case authorName = "author_name" + case authorEmail = "author_email" + case authorURL = "author_url" + case authorIP = "author_ip" + case authorUserAgent = "author_user_agent" + case date = "date_gmt" // take the gmt version, as the other `date` parameter doesn't provide timezone information. + case content + case authorAvatarURLs = "author_avatar_urls" + case link + case status + case type + } + + enum ContentCodingKeys: String, CodingKey { + case rendered + case raw + } + + enum AuthorAvatarCodingKeys: String, CodingKey { + case size96 = "96" // this is the default size for avatar URL in API v1.1. + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + self.commentID = try container.decode(Int.self, forKey: .id) + self.postID = try container.decode(Int.self, forKey: .post) + self.parentID = try container.decode(Int.self, forKey: .parent) + self.authorID = try container.decode(Int.self, forKey: .author) + self.authorName = try container.decode(String.self, forKey: .authorName) + self.authorEmail = try container.decodeIfPresent(String.self, forKey: .authorEmail) + self.authorURL = try container.decode(String.self, forKey: .authorURL) + self.authorIP = try container.decodeIfPresent(String.self, forKey: .authorIP) + self.authorUserAgent = try container.decodeIfPresent(String.self, forKey: .authorUserAgent) + self.link = try container.decode(String.self, forKey: .link) + self.type = try container.decode(String.self, forKey: .type) + + // since `date_gmt` is already in GMT timezone, manually add the timezone string to make the rfc3339 formatter happy (or it will throw otherwise). + guard let dateString = try? container.decode(String.self, forKey: .date), + let date = NSDate.with(wordPressComJSONString: dateString + "+00:00") else { + throw DecodingError.dataCorruptedError(forKey: .date, in: container, debugDescription: "Date parsing failed") + } + self.date = date + + let contentContainer = try container.nestedContainer(keyedBy: ContentCodingKeys.self, forKey: .content) + self.rawContent = try contentContainer.decodeIfPresent(String.self, forKey: .raw) + self.content = try contentContainer.decode(String.self, forKey: .rendered) + + let remoteStatus = try container.decode(String.self, forKey: .status) + self.status = Self.status(from: remoteStatus) + + let avatarContainer = try container.nestedContainer(keyedBy: AuthorAvatarCodingKeys.self, forKey: .authorAvatarURLs) + self.authorAvatarURL = try avatarContainer.decode(String.self, forKey: .size96) + } + + /// Maintain parity with the client-side comment statuses. Refer to `CommentServiceRemoteREST:statusWithRemoteStatus`. + private static func status(from remoteStatus: String) -> String { + switch remoteStatus { + case "unapproved": + return "hold" + case "approved": + return "approve" + default: + return remoteStatus + } + } +} diff --git a/Modules/Sources/WordPressKit/RemoteConfigRemote.swift b/Modules/Sources/WordPressKit/RemoteConfigRemote.swift new file mode 100644 index 000000000000..38b01c0d1d6b --- /dev/null +++ b/Modules/Sources/WordPressKit/RemoteConfigRemote.swift @@ -0,0 +1,37 @@ +import Foundation + +open class RemoteConfigRemote: ServiceRemoteWordPressComREST { + + public typealias RemoteConfigDictionary = [String: Any] + public typealias RemoteConfigResponseCallback = (Result) -> Void + + public enum RemoteConfigRemoteError: Error { + case InvalidDataError + } + + open func getRemoteConfig(callback: @escaping RemoteConfigResponseCallback) { + + let endpoint = "mobile/remote-config" + let path = self.path(forEndpoint: endpoint, withVersion: ._2_0) + + wordPressComRESTAPI.get(path, + parameters: nil, + success: { response, _ in + if let remoteConfigDictionary = response as? [String: Any] { + callback(.success(remoteConfigDictionary)) + } else { + callback(.failure(RemoteConfigRemoteError.InvalidDataError)) + } + + }, failure: { error, response in + WPKitLogError("Error retrieving remote config values") + WPKitLogError("\(error)") + + if let response { + WPKitLogDebug("Response Code: \(response.statusCode)") + } + + callback(.failure(error)) + }) + } +} diff --git a/Modules/Sources/WordPressKit/RemoteDiff.swift b/Modules/Sources/WordPressKit/RemoteDiff.swift new file mode 100644 index 000000000000..b93f8543755c --- /dev/null +++ b/Modules/Sources/WordPressKit/RemoteDiff.swift @@ -0,0 +1,110 @@ +import Foundation + +/// RemoteDiff model +public struct RemoteDiff: Codable { + /// Revision id from the content has been changed + public var fromRevisionId: Int + + /// Current revision id + public var toRevisionId: Int + + /// Model for the diff values + public var values: RemoteDiffValues + + /// Mapping keys + private enum CodingKeys: String, CodingKey { + case fromRevisionId = "from" + case toRevisionId = "to" + case values = "diff" + } + + // MARK: - Decode protocol + + public init(from decoder: Decoder) throws { + let data = try decoder.container(keyedBy: CodingKeys.self) + + fromRevisionId = (try? data.decode(Int.self, forKey: .fromRevisionId)) ?? 0 + toRevisionId = (try? data.decode(Int.self, forKey: .toRevisionId)) ?? 0 + values = try data.decode(RemoteDiffValues.self, forKey: .values) + } +} + +/// RemoteDiffValues model +public struct RemoteDiffValues: Codable { + /// Model for the diff totals operations + public var totals: RemoteDiffTotals? + + /// Title diffs array + public var titleDiffs: [RemoteDiffValue] + + /// Content diffs array + public var contentDiffs: [RemoteDiffValue] + + /// Mapping keys + private enum CodingKeys: String, CodingKey { + case titleDiffs = "post_title" + case contentDiffs = "post_content" + case totals + } +} + +/// RemoteDiffTotals model +public struct RemoteDiffTotals: Codable { + /// Total of additional operations + public var totalAdditions: Int + + /// Total of deletions operations + public var totalDeletions: Int + + /// Mapping keys + private enum CodingKeys: String, CodingKey { + case totalAdditions = "add" + case totalDeletions = "del" + } + + // MARK: - Decode protocol + + public init(from decoder: Decoder) throws { + let data = try decoder.container(keyedBy: CodingKeys.self) + + totalAdditions = (try? data.decode(Int.self, forKey: .totalAdditions)) ?? 0 + totalDeletions = (try? data.decode(Int.self, forKey: .totalDeletions)) ?? 0 + } +} + +/// RemoteDiffOperation enumeration +/// +/// - add: Addition +/// - copy: Copy +/// - del: Deletion +/// - unknown: Default value +public enum RemoteDiffOperation: String, Codable { + case add + case copy + case del + case unknown +} + +/// DiffValue +public struct RemoteDiffValue: Codable { + /// Diff operation + public var operation: RemoteDiffOperation + + /// Diff value + public var value: String? + + /// Mapping keys + private enum CodingKeys: String, CodingKey { + case operation = "op" + case value + } + + // MARK: - Decode protocol + + public init(from decoder: Decoder) throws { + let data = try decoder.container(keyedBy: CodingKeys.self) + + operation = (try? data.decode(RemoteDiffOperation.self, forKey: .operation)) ?? .unknown + value = try? data.decode(String.self, forKey: .value) + } +} diff --git a/Modules/Sources/WordPressKit/RemoteDomain.swift b/Modules/Sources/WordPressKit/RemoteDomain.swift new file mode 100644 index 000000000000..d1dd5a6e9e11 --- /dev/null +++ b/Modules/Sources/WordPressKit/RemoteDomain.swift @@ -0,0 +1,83 @@ +import Foundation + +@objc public enum DomainType: Int16 { + case registered + case mapped + case siteRedirect + case transfer + case wpCom + + public var description: String { + switch self { + case .registered: + return NSLocalizedString("Registered Domain", comment: "Describes a domain that was registered with WordPress.com") + case .mapped: + return NSLocalizedString("Mapped Domain", comment: "Describes a domain that was mapped to WordPress.com, but registered elsewhere") + case .siteRedirect: + return NSLocalizedString("Site Redirect", comment: "Describes a site redirect domain") + case .wpCom: + return NSLocalizedString("Included with Site", comment: "Describes a standard *.wordpress.com site domain") + case .transfer: + return NSLocalizedString("Transferred Domain", comment: "Describes a domain that was transferred from elsewhere to wordpress.com") + } + } + + init(domainJson: [String: Any]) { + self.init( + type: domainJson["domain"] as? String, + wpComDomain: domainJson["wpcom_domain"] as? Bool, + hasRegistration: domainJson["has_registration"] as? Bool + ) + } + + init(type: String?, wpComDomain: Bool?, hasRegistration: Bool?) { + if type == "redirect" { + self = .siteRedirect + } else if type == "transfer" { + self = .transfer + } else if wpComDomain == true { + self = .wpCom + } else if hasRegistration == true { + self = .registered + } else { + self = .mapped + } + } +} + +public struct RemoteDomain { + public let domainName: String + public let isPrimaryDomain: Bool + public let domainType: DomainType + + // Renewals / Expiry + public let autoRenewing: Bool + public let autoRenewalDate: String + public let expirySoon: Bool + public let expired: Bool + public let expiryDate: String + + public init(domainName: String, + isPrimaryDomain: Bool, + domainType: DomainType, + autoRenewing: Bool? = nil, + autoRenewalDate: String? = nil, + expirySoon: Bool? = nil, + expired: Bool? = nil, + expiryDate: String? = nil) { + self.domainName = domainName + self.isPrimaryDomain = isPrimaryDomain + self.domainType = domainType + self.autoRenewing = autoRenewing ?? false + self.autoRenewalDate = autoRenewalDate ?? "" + self.expirySoon = expirySoon ?? false + self.expired = expired ?? false + self.expiryDate = expiryDate ?? "" + } +} + +extension RemoteDomain: CustomStringConvertible { + public var description: String { + return "\(domainName) (\(domainType.description))" + } +} diff --git a/Modules/Sources/WordPressKit/RemoteGravatarProfile.swift b/Modules/Sources/WordPressKit/RemoteGravatarProfile.swift new file mode 100644 index 000000000000..f55ddb12bca3 --- /dev/null +++ b/Modules/Sources/WordPressKit/RemoteGravatarProfile.swift @@ -0,0 +1,34 @@ +import Foundation + +public class RemoteGravatarProfile { + public let profileID: String + public let hash: String + public let requestHash: String + public let profileUrl: String + public let preferredUsername: String + public let thumbnailUrl: String + public let name: String + public let displayName: String + public let formattedName: String + public let aboutMe: String + public let currentLocation: String + + init(dictionary: NSDictionary) { + profileID = dictionary.string(forKey: "id") ?? "" + hash = dictionary.string(forKey: "hash") ?? "" + requestHash = dictionary.string(forKey: "requestHash") ?? "" + profileUrl = dictionary.string(forKey: "profileUrl") ?? "" + preferredUsername = dictionary.string(forKey: "preferredUsername") ?? "" + thumbnailUrl = dictionary.string(forKey: "thumbnailUrl") ?? "" + name = dictionary.string(forKey: "name") ?? "" + displayName = dictionary.string(forKey: "displayName") ?? "" + + if let nameDictionary = dictionary.value(forKey: "name") as? NSDictionary { + formattedName = nameDictionary.string(forKey: "formatted") ?? "" + } else { + formattedName = "" + } + aboutMe = dictionary.string(forKey: "aboutMe") ?? "" + currentLocation = dictionary.string(forKey: "currentLocation") ?? "" + } +} diff --git a/Modules/Sources/WordPressKit/RemoteHomepageType.swift b/Modules/Sources/WordPressKit/RemoteHomepageType.swift new file mode 100644 index 000000000000..c6d7d4acc2c3 --- /dev/null +++ b/Modules/Sources/WordPressKit/RemoteHomepageType.swift @@ -0,0 +1,12 @@ +import Foundation + +/// The type of homepage used by a site: blog posts (.posts), or static pages (.page). +public enum RemoteHomepageType { + case page + case posts + + /// True if the site uses a page for its front page, rather than blog posts + internal var isPageOnFront: Bool { + return self == .page + } +} diff --git a/Modules/Sources/WordPressKit/RemoteInviteLink.swift b/Modules/Sources/WordPressKit/RemoteInviteLink.swift new file mode 100644 index 000000000000..0f7d8a512887 --- /dev/null +++ b/Modules/Sources/WordPressKit/RemoteInviteLink.swift @@ -0,0 +1,26 @@ +import Foundation + +public struct RemoteInviteLink { + public let inviteKey: String + public let role: String + public let isPending: Bool + public let inviteDate: Date + public let groupInvite: Bool + public let expiry: Int64 + public let link: String + + init(dict: [String: Any]) { + var date = Date() + if let inviteDate = dict["invite_date"] as? String, + let formattedDate = ISO8601DateFormatter().date(from: inviteDate) { + date = formattedDate + } + inviteKey = dict["invite_key"] as? String ?? "" + role = dict["role"] as? String ?? "" + isPending = dict["is_pending"] as? Bool ?? false + inviteDate = date + groupInvite = dict["is_group_invite"] as? Bool ?? false + expiry = dict["expiry"] as? Int64 ?? 0 + link = dict["link"] as? String ?? "" + } +} diff --git a/Modules/Sources/WordPressKit/RemoteNotification.swift b/Modules/Sources/WordPressKit/RemoteNotification.swift new file mode 100644 index 000000000000..f97bfa2c0d99 --- /dev/null +++ b/Modules/Sources/WordPressKit/RemoteNotification.swift @@ -0,0 +1,80 @@ +import Foundation + +// MARK: - RemoteNotification +// +public struct RemoteNotification { + /// Notification's Primary Key + /// + public let notificationId: String + + /// Notification's Hash + /// + public let notificationHash: String + + /// Indicates whether the note was already read, or not + /// + public let read: Bool + + /// Associated Resource's Icon, as a plain string + /// + public let icon: String? + + /// Noticon resource, associated with this notification + /// + public let noticon: String? + + /// Timestamp as a String + /// + public let timestamp: String? + + /// Notification Type + /// + public let type: String? + + /// Associated Resource's URL + /// + public let url: String? + + /// Plain Title ("1 Like" / Etc) + /// + public let title: String? + + /// Raw Subject Blocks + /// + public let subject: [AnyObject]? + + /// Raw Header Blocks + /// + public let header: [AnyObject]? + + /// Raw Body Blocks + /// + public let body: [AnyObject]? + + /// Raw Associated Metadata + /// + public let meta: [String: AnyObject]? + + /// Designed Initializer + /// + public init?(document: [String: AnyObject]) { + guard let noteId = document.valueAsString(forKey: "id"), + let noteHash = document.valueAsString(forKey: "note_hash") else { + return nil + } + + notificationId = noteId + notificationHash = noteHash + read = document["read"] as? Bool ?? false + icon = document["icon"] as? String + noticon = document["noticon"] as? String + timestamp = document["timestamp"] as? String + type = document["type"] as? String + url = document["url"] as? String + title = document["title"] as? String + subject = document["subject"] as? [AnyObject] + header = document["header"] as? [AnyObject] + body = document["body"] as? [AnyObject] + meta = document["meta"] as? [String: AnyObject] + } +} diff --git a/Modules/Sources/WordPressKit/RemoteNotificationSettings.swift b/Modules/Sources/WordPressKit/RemoteNotificationSettings.swift new file mode 100644 index 000000000000..76a3af9a415d --- /dev/null +++ b/Modules/Sources/WordPressKit/RemoteNotificationSettings.swift @@ -0,0 +1,190 @@ +import Foundation + +/// The goal of this class is to parse Notification Settings data from the backend, and structure it in a +/// meaningful way. Notification Settings come in three different flavors: +/// +/// - "Our Own" Blog Settings +/// - "Third Party" Site Settings +/// - WordPress.com Settings +/// +/// Each one of the possible channels may post notifications via different streams: +/// Email, Push Notifications, and Timeline. +/// +open class RemoteNotificationSettings { + /// Represents the Channel to which the current settings are associated. + /// + public let channel: Channel + + /// Contains an array of the available Notification Streams. + /// + public let streams: [Stream] + + /// Represents a communication channel that may post notifications to the user. + /// + @frozen public enum Channel: Equatable { + case blog(blogId: Int) + case other + case wordPressCom + } + + /// Contains the Notification Settings for a specific communications stream. + /// + open class Stream { + open var kind: Kind + open var preferences: [String: Bool]? + + /// Enumerates all of the possible Stream Kinds + /// + public enum Kind: String { + // swiftlint:disable operator_usage_whitespace + case Timeline = "timeline" + case Email = "email" + case Device = "device" + // swiftlint:enable operator_usage_whitespace + + static let allValues = [ Timeline, Email, Device ] + } + + /// Private Designated Initializer + /// + /// - Parameters: + /// - kind: The Kind of stream we're currently dealing with + /// - preferences: Raw remote preferences, retrieved from the backend + /// + fileprivate init(kind: Kind, preferences: NSDictionary?) { + // swiftlint:disable operator_usage_whitespace + self.kind = kind + self.preferences = filterNonBooleanEntries(preferences) + // swiftlint:enable operator_usage_whitespace + } + + /// Helper method that will filter out non boolean entries, and return a native Swift collection. + /// + /// - Parameter dictionary: NextStep Dictionary containing raw values + /// + /// - Returns: A native Swift dictionary, containing only the Boolean entries + /// + private func filterNonBooleanEntries(_ dictionary: NSDictionary?) -> [String: Bool] { + var filtered = [String: Bool]() + if dictionary == nil { + return filtered + } + + for (key, value) in dictionary! { + if let stringKey = key as? String, + let boolValue = value as? Bool { + let value = value as AnyObject + // NSNumbers might get converted to Bool anyways + if value === kCFBooleanFalse || value === kCFBooleanTrue { + filtered[stringKey] = boolValue + } + } + } + + return filtered + } + + /// Parser method that will convert a raw dictionary of stream settings into Swift Native objects. + /// + /// - Parameter dictionary: NextStep Dictionary containing raw Stream Preferences + /// + /// - Returns: A native Swift array containing Stream entities + /// + fileprivate static func fromDictionary(_ dictionary: NSDictionary?) -> [Stream] { + var parsed = [Stream]() + + for kind in Kind.allValues { + if let preferences = dictionary?[kind.rawValue] as? NSDictionary { + parsed.append(Stream(kind: kind, preferences: preferences)) + } + } + + return parsed + } + } + + /// Private Designated Initializer + /// + /// - Parameters: + /// - channel: The communications channel that uses the current settings + /// - settings: Raw dictionary containing the remote settings response + /// + private init(channel: Channel, settings: NSDictionary?) { + self.channel = channel + self.streams = Stream.fromDictionary(settings) + } + + /// Private Designated Initializer + /// + /// - Parameter wpcomSettings: Dictionary containing the collection of WordPress.com Settings + /// + private init(wpcomSettings: NSDictionary?) { + // WordPress.com is a special scenario: It contains just one (unspecified) stream: Email + self.channel = Channel.wordPressCom + self.streams = [ Stream(kind: .Email, preferences: wpcomSettings) ] + } + + /// Private Convenience Initializer + /// + /// - Parameter blogSettings: Dictionary containing the collection of settings for a single blog + /// + private convenience init(blogSettings: NSDictionary?) { + let blogId = blogSettings?["blog_id"] as? Int ?? Int.max + self.init(channel: Channel.blog(blogId: blogId), settings: blogSettings) + } + + /// Private Convenience Initializer + /// + /// - Parameter otherSettings: Dictionary containing the collection of "Other Settings" + /// + private convenience init(otherSettings: NSDictionary?) { + self.init(channel: Channel.other, settings: otherSettings) + } + + /// Static Helper that will parse all of the Remote Settings, into a collection of Swift Native + /// RemoteNotificationSettings objects. + /// + /// - Parameter dictionary: Dictionary containing the remote Settings response + /// + /// - Returns: An array of RemoteNotificationSettings objects + /// + public static func fromDictionary(_ dictionary: NSDictionary?) -> [RemoteNotificationSettings] { + var parsed = [RemoteNotificationSettings]() + + if let rawBlogs = dictionary?["blogs"] as? [NSDictionary] { + for rawBlog in rawBlogs { + let parsedBlog = RemoteNotificationSettings(blogSettings: rawBlog) + parsed.append(parsedBlog) + } + } + + let other = RemoteNotificationSettings(otherSettings: dictionary?["other"] as? NSDictionary) + parsed.append(other) + + let wpcom = RemoteNotificationSettings(wpcomSettings: dictionary?["wpcom"] as? NSDictionary) + parsed.append(wpcom) + + return parsed + } +} + +/// Swift requires this method to be implemented globally. Sorry about that! +/// +/// - Parameters: +/// - lhs: Left Hand Side Channel +/// - rhs: Right Hand Side Channel +/// +/// - Returns: A boolean indicating whether two channels are equal. Or not! +/// +public func ==(lhs: RemoteNotificationSettings.Channel, rhs: RemoteNotificationSettings.Channel) -> Bool { + switch (lhs, rhs) { + case (let .blog(firstBlogId), let .blog(secondBlogId)) where firstBlogId == secondBlogId: + return true + case (.other, .other): + return true + case (.wordPressCom, .wordPressCom): + return true + default: + return false + } +} diff --git a/Modules/Sources/WordPressKit/RemotePageLayouts.swift b/Modules/Sources/WordPressKit/RemotePageLayouts.swift new file mode 100644 index 000000000000..42fcb9f9b827 --- /dev/null +++ b/Modules/Sources/WordPressKit/RemotePageLayouts.swift @@ -0,0 +1,86 @@ +import Foundation + +public struct RemotePageLayouts: Codable { + public let layouts: [RemoteLayout] + public let categories: [RemoteLayoutCategory] + + enum CodingKeys: String, CodingKey { + case layouts + case categories + } + + public init(from decoder: Decoder) throws { + let map = try decoder.container(keyedBy: CodingKeys.self) + layouts = try map.decode([RemoteLayout].self, forKey: .layouts) + categories = try map.decode([RemoteLayoutCategory].self, forKey: .categories) + } + + public init() { + self.init(layouts: [], categories: []) + } + + public init(layouts: [RemoteLayout], categories: [RemoteLayoutCategory]) { + self.layouts = layouts + self.categories = categories + } +} + +public struct RemoteLayout: Codable { + public let slug: String + public let title: String + public let preview: String? + public let previewTablet: String? + public let previewMobile: String? + public let demoUrl: String? + public let content: String? + public let categories: [RemoteLayoutCategory] + + enum CodingKeys: String, CodingKey { + case slug + case title + case preview + case previewTablet = "preview_tablet" + case previewMobile = "preview_mobile" + case demoUrl = "demo_url" + case content + case categories + } + + public init(from decoder: Decoder) throws { + let map = try decoder.container(keyedBy: CodingKeys.self) + slug = try map.decode(String.self, forKey: .slug) + title = try map.decode(String.self, forKey: .title) + preview = try? map.decode(String.self, forKey: .preview) + previewTablet = try? map.decode(String.self, forKey: .previewTablet) + previewMobile = try? map.decode(String.self, forKey: .previewMobile) + demoUrl = try? map.decode(String.self, forKey: .demoUrl) + content = try? map.decode(String.self, forKey: .content) + categories = try map.decode([RemoteLayoutCategory].self, forKey: .categories) + } +} + +public struct RemoteLayoutCategory: Codable, Comparable { + public static func < (lhs: RemoteLayoutCategory, rhs: RemoteLayoutCategory) -> Bool { + return lhs.slug < rhs.slug + } + + public let slug: String + public let title: String + public let description: String + public let emoji: String? + + enum CodingKeys: String, CodingKey { + case slug + case title + case description + case emoji + } + + public init(from decoder: Decoder) throws { + let map = try decoder.container(keyedBy: CodingKeys.self) + slug = try map.decode(String.self, forKey: .slug) + title = try map.decode(String.self, forKey: .title) + description = try map.decode(String.self, forKey: .description) + emoji = try? map.decode(String.self, forKey: .emoji) + } +} diff --git a/Modules/Sources/WordPressKit/RemotePerson.swift b/Modules/Sources/WordPressKit/RemotePerson.swift new file mode 100644 index 000000000000..3ec08544b5ce --- /dev/null +++ b/Modules/Sources/WordPressKit/RemotePerson.swift @@ -0,0 +1,260 @@ +import Foundation + +// MARK: - Defines all of the peroperties a Person may have +// +public protocol RemotePerson { + /// Properties + /// + var ID: Int { get } + var username: String { get } + var firstName: String? { get } + var lastName: String? { get } + var displayName: String { get } + var role: String { get } + var siteID: Int { get } + var linkedUserID: Int { get } + var avatarURL: URL? { get } + var isSuperAdmin: Bool { get } + var fullName: String { get } + + /// Static Properties + /// + static var kind: PersonKind { get } + + /// Initializers + /// + init(ID: Int, + username: String, + firstName: String?, + lastName: String?, + displayName: String, + role: String, + siteID: Int, + linkedUserID: Int, + avatarURL: URL?, + isSuperAdmin: Bool) +} + +// MARK: - Specifies all of the Roles a Person may have +// +public struct RemoteRole { + public let slug: String + public let name: String + + public init(slug: String, name: String) { + self.slug = slug + self.name = name + } +} + +extension RemoteRole { + public static let viewer = RemoteRole(slug: "follower", name: NSLocalizedString("Viewer", comment: "User role badge")) + public static let follower = RemoteRole(slug: "follower", name: NSLocalizedString("Follower", comment: "User role badge")) +} + +// MARK: - Specifies all of the possible Person Types that might exist. +// +public enum PersonKind: Int { + case user + case follower + case viewer + case emailFollower +} + +// MARK: - Defines a Blog's User +// +public struct User: RemotePerson { + public let ID: Int + public let username: String + public let firstName: String? + public let lastName: String? + public let displayName: String + public let role: String + public let siteID: Int + public let linkedUserID: Int + public let avatarURL: URL? + public let isSuperAdmin: Bool + public static let kind = PersonKind.user + + public init(ID: Int, + username: String, + firstName: String?, + lastName: String?, + displayName: String, + role: String, + siteID: Int, + linkedUserID: Int, + avatarURL: URL?, + isSuperAdmin: Bool) { + self.ID = ID + self.username = username + self.firstName = firstName + self.lastName = lastName + self.displayName = displayName + self.role = role + self.siteID = siteID + self.linkedUserID = linkedUserID + self.avatarURL = avatarURL + self.isSuperAdmin = isSuperAdmin + } +} + +// MARK: - Defines a Blog's Follower +// +public struct Follower: RemotePerson { + public let ID: Int + public let username: String + public let firstName: String? + public let lastName: String? + public let displayName: String + public let role: String + public let siteID: Int + public let linkedUserID: Int + public let avatarURL: URL? + public let isSuperAdmin: Bool + public static let kind = PersonKind.follower + + public init(ID: Int, + username: String, + firstName: String?, + lastName: String?, + displayName: String, + role: String, + siteID: Int, + linkedUserID: Int, + avatarURL: URL?, + isSuperAdmin: Bool) { + self.ID = ID + self.username = username + self.firstName = firstName + self.lastName = lastName + self.displayName = displayName + self.role = role + self.siteID = siteID + self.linkedUserID = linkedUserID + self.avatarURL = avatarURL + self.isSuperAdmin = isSuperAdmin + } +} + +// MARK: - Defines a Blog's Viewer +// +public struct Viewer: RemotePerson { + public let ID: Int + public let username: String + public let firstName: String? + public let lastName: String? + public let displayName: String + public let role: String + public let siteID: Int + public let linkedUserID: Int + public let avatarURL: URL? + public let isSuperAdmin: Bool + public static let kind = PersonKind.viewer + + public init(ID: Int, + username: String, + firstName: String?, + lastName: String?, + displayName: String, + role: String, + siteID: Int, + linkedUserID: Int, + avatarURL: URL?, + isSuperAdmin: Bool) { + self.ID = ID + self.username = username + self.firstName = firstName + self.lastName = lastName + self.displayName = displayName + self.role = role + self.siteID = siteID + self.linkedUserID = linkedUserID + self.avatarURL = avatarURL + self.isSuperAdmin = isSuperAdmin + } +} + +// MARK: - Defines a Blog's Email Follower +// +public struct EmailFollower: RemotePerson { + public let ID: Int + public let username: String + public let firstName: String? + public let lastName: String? + public let displayName: String + public let role: String + public let siteID: Int + public let linkedUserID: Int + public let avatarURL: URL? + public let isSuperAdmin: Bool + public static let kind = PersonKind.emailFollower + + public init(ID: Int, + username: String, + firstName: String?, + lastName: String?, + displayName: String, + role: String, + siteID: Int, + linkedUserID: Int, + avatarURL: URL?, + isSuperAdmin: Bool) { + self.ID = ID + self.username = username + self.firstName = firstName + self.lastName = lastName + self.displayName = displayName + self.role = role + self.siteID = siteID + self.linkedUserID = linkedUserID + self.avatarURL = avatarURL + self.isSuperAdmin = isSuperAdmin + } + + public init?(siteID: Int, statsFollower: StatsFollower?) { + guard let statsFollower, + let stringId = statsFollower.id, + let id = Int(stringId) else { + return nil + } + + self.ID = id + self.username = "" + self.firstName = nil + self.lastName = nil + self.displayName = statsFollower.name + self.role = "" + self.siteID = siteID + self.linkedUserID = id + self.avatarURL = statsFollower.avatarURL + self.isSuperAdmin = false + } +} +// MARK: - Extensions +// +public extension RemotePerson { + var fullName: String { + let first = firstName ?? String() + let last = lastName ?? String() + let separator = (first.isEmpty == false && last.isEmpty == false) ? " " : "" + + return "\(first)\(separator)\(last)" + } +} + +// MARK: - Operator Overloading +// +public func ==(lhs: T, rhs: T) -> Bool { + return lhs.ID == rhs.ID + && lhs.username == rhs.username + && lhs.firstName == rhs.firstName + && lhs.lastName == rhs.lastName + && lhs.displayName == rhs.displayName + && lhs.role == rhs.role + && lhs.siteID == rhs.siteID + && lhs.linkedUserID == rhs.linkedUserID + && lhs.avatarURL == rhs.avatarURL + && lhs.isSuperAdmin == rhs.isSuperAdmin + && type(of: lhs) == type(of: rhs) +} diff --git a/Modules/Sources/WordPressKit/RemotePlan_ApiVersion1_3.swift b/Modules/Sources/WordPressKit/RemotePlan_ApiVersion1_3.swift new file mode 100644 index 000000000000..882bc6a6809c --- /dev/null +++ b/Modules/Sources/WordPressKit/RemotePlan_ApiVersion1_3.swift @@ -0,0 +1,44 @@ +import Foundation + +/// This is for getPlansForSite service in api version v1.3. +/// There are some huge differences between v1.3 and v1.2 so a new +/// class is created for v1.3. +@objc public class RemotePlan_ApiVersion1_3: NSObject, Codable { + public var autoRenew: Bool? + public var freeTrial: Bool? + public var interval: Int? + public var rawDiscount: Double? + public var rawPrice: Double? + public var hasDomainCredit: Bool? + public var currentPlan: Bool? + public var userIsOwner: Bool? + public var isDomainUpgrade: Bool? + @objc public var autoRenewDate: Date? + @objc public var currencyCode: String? + @objc public var discountReason: String? + @objc public var expiry: Date? + @objc public var formattedDiscount: String? + @objc public var formattedOriginalPrice: String? + @objc public var formattedPrice: String? + @objc public var planID: String? + @objc public var productName: String? + @objc public var productSlug: String? + @objc public var subscribedDate: Date? + @objc public var userFacingExpiry: Date? + + @objc public var isAutoRenew: Bool { + return autoRenew ?? false + } + + @objc public var isCurrentPlan: Bool { + return currentPlan ?? false + } + + @objc public var isFreeTrial: Bool { + return freeTrial ?? false + } + + @objc public var doesHaveDomainCredit: Bool { + return hasDomainCredit ?? false + } +} diff --git a/Modules/Sources/WordPressKit/RemotePostParameters.swift b/Modules/Sources/WordPressKit/RemotePostParameters.swift new file mode 100644 index 000000000000..a89a1b17694a --- /dev/null +++ b/Modules/Sources/WordPressKit/RemotePostParameters.swift @@ -0,0 +1,448 @@ +import Foundation + +/// The parameters required to create a post or a page. +public struct RemotePostCreateParameters: Equatable { + public var type: String + + public var status: String + public var date: Date? + public var authorID: Int? + public var title: String? + public var content: String? + public var password: String? + public var excerpt: String? + public var slug: String? + public var featuredImageID: Int? + + // Pages + public var parentPageID: Int? + + // Posts + public var format: String? + public var isSticky = false + public var tags: [String] = [] + public var categoryIDs: [Int] = [] + public var metadata: Set = [] + + public init(type: String, status: String) { + self.type = type + self.status = status + } +} + +/// Represents a partial update to be applied to a post or a page. +public struct RemotePostUpdateParameters: Equatable { + public var ifNotModifiedSince: Date? + + public var status: String? + public var date: Date? + public var authorID: Int? + public var title: String?? + public var content: String?? + public var password: String?? + public var excerpt: String?? + public var slug: String?? + public var featuredImageID: Int?? + + // Pages + public var parentPageID: Int?? + + // Posts + public var format: String?? + public var isSticky: Bool? + public var tags: [String]? + public var categoryIDs: [Int]? + public var metadata: Set? + + public init() {} +} + +public struct RemotePostMetadataItem: Hashable { + public var id: String? + public var key: String? + public var value: String? + + public init(id: String?, key: String?, value: String?) { + self.id = id + self.key = key + self.value = value + } +} + +// MARK: - Diff + +extension RemotePostCreateParameters { + /// Returns a diff required to update from the `previous` to the current + /// version of the post. + public func changes(from previous: RemotePostCreateParameters) -> RemotePostUpdateParameters { + var changes = RemotePostUpdateParameters() + if previous.status != status { + changes.status = status + } + if previous.date != date { + changes.date = date + } + if previous.authorID != authorID { + changes.authorID = authorID + } + if (previous.title ?? "") != (title ?? "") { + changes.title = (title ?? "") + } + if (previous.content ?? "") != (content ?? "") { + changes.content = (content ?? "") + } + if (previous.password ?? "") != (password ?? "") { + changes.password = password + } + if (previous.excerpt ?? "") != (excerpt ?? "") { + changes.excerpt = (excerpt ?? "") + } + if (previous.slug ?? "") != (slug ?? "") { + changes.slug = (slug ?? "") + } + if previous.featuredImageID != featuredImageID { + changes.featuredImageID = featuredImageID + } + if previous.parentPageID != parentPageID { + changes.parentPageID = parentPageID + } + if previous.format != format { + changes.format = format + } + if previous.isSticky != isSticky { + changes.isSticky = isSticky + } + if previous.tags != tags { + changes.tags = tags + } + if Set(previous.categoryIDs) != Set(categoryIDs) { + changes.categoryIDs = categoryIDs + } + if previous.metadata != metadata { + changes.metadata = metadata + } + return changes + } + + /// Applies the diff to the receiver. + public mutating func apply(_ changes: RemotePostUpdateParameters) { + if let status = changes.status { + self.status = status + } + if let date = changes.date { + self.date = date + } + if let authorID = changes.authorID { + self.authorID = authorID + } + if let title = changes.title { + self.title = title + } + if let content = changes.content { + self.content = content + } + if let password = changes.password { + self.password = password + } + if let excerpt = changes.excerpt { + self.excerpt = excerpt + } + if let slug = changes.slug { + self.slug = slug + } + if let featuredImageID = changes.featuredImageID { + self.featuredImageID = featuredImageID + } + if let parentPageID = changes.parentPageID { + self.parentPageID = parentPageID + } + if let format = changes.format { + self.format = format + } + if let isSticky = changes.isSticky { + self.isSticky = isSticky + } + if let tags = changes.tags { + self.tags = tags + } + if let categoryIDs = changes.categoryIDs { + self.categoryIDs = categoryIDs + } + if let metadata = changes.metadata { + self.metadata = metadata + } + } +} + +// MARK: - Encoding (WP.COM REST API) + +private enum RemotePostWordPressComCodingKeys: String, CodingKey { + case ifNotModifiedSince = "if_not_modified_since" + case type + case status + case date + case authorID = "author" + case title + case content + case password + case excerpt + case slug + case featuredImageID = "featured_image" + case parentPageID = "parent" + case terms + case format + case isSticky = "sticky" + case categoryIDs = "categories_by_id" + case metadata + + static let postTags = "post_tag" +} + +struct RemotePostCreateParametersWordPressComEncoder: Encodable { + let parameters: RemotePostCreateParameters + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: RemotePostWordPressComCodingKeys.self) + try container.encodeIfPresent(parameters.type, forKey: .type) + try container.encodeIfPresent(parameters.status, forKey: .status) + try container.encodeIfPresent(parameters.date, forKey: .date) + try container.encodeIfPresent(parameters.authorID, forKey: .authorID) + try container.encodeIfPresent(parameters.title, forKey: .title) + try container.encodeIfPresent(parameters.content, forKey: .content) + try container.encodeIfPresent(parameters.password, forKey: .password) + try container.encodeIfPresent(parameters.excerpt, forKey: .excerpt) + try container.encodeIfPresent(parameters.slug, forKey: .slug) + try container.encodeIfPresent(parameters.featuredImageID, forKey: .featuredImageID) + if !parameters.metadata.isEmpty { + let metadata = parameters.metadata.map(RemotePostUpdateParametersWordPressComMetadata.init) + try container.encode(metadata, forKey: .metadata) + } + + // Pages + try container.encodeIfPresent(parameters.parentPageID, forKey: .parentPageID) + + // Posts + try container.encodeIfPresent(parameters.format, forKey: .format) + if !parameters.tags.isEmpty { + try container.encode([RemotePostWordPressComCodingKeys.postTags: parameters.tags], forKey: .terms) + } + if !parameters.categoryIDs.isEmpty { + try container.encodeIfPresent(parameters.categoryIDs, forKey: .categoryIDs) + } + if parameters.isSticky { + try container.encode(parameters.isSticky, forKey: .isSticky) + } + } + + // - warning: fixme + static func encodeMetadata(_ metadata: Set) -> [[String: Any]] { + metadata.map { item in + var operation = "update" + if item.key == nil { + if item.id != nil && item.value == nil { + operation = "delete" + } else if item.id == nil && item.value != nil { + operation = "add" + } + } + var dictionary: [String: Any] = [:] + if let id = item.id { dictionary["id"] = id } + if let value = item.value { dictionary["value"] = value } + if let key = item.key { dictionary["key"] = key } + dictionary["operation"] = operation + return dictionary + } + } +} + +struct RemotePostUpdateParametersWordPressComMetadata: Encodable { + let id: String? + let operation: String + let key: String? + let value: String? + + init(_ item: RemotePostMetadataItem) { + if item.key == nil { + if item.id != nil && item.value == nil { + self.operation = "delete" + } else if item.id == nil && item.value != nil { + self.operation = "add" + } else { + self.operation = "update" + } + } else { + self.operation = "update" + } + self.id = item.id + self.key = item.key + self.value = item.value + } +} + +struct RemotePostUpdateParametersWordPressComEncoder: Encodable { + let parameters: RemotePostUpdateParameters + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: RemotePostWordPressComCodingKeys.self) + try container.encodeIfPresent(parameters.ifNotModifiedSince, forKey: .ifNotModifiedSince) + + try container.encodeIfPresent(parameters.status, forKey: .status) + try container.encodeIfPresent(parameters.date, forKey: .date) + try container.encodeIfPresent(parameters.authorID, forKey: .authorID) + try container.encodeStringIfPresent(parameters.title, forKey: .title) + try container.encodeStringIfPresent(parameters.content, forKey: .content) + try container.encodeStringIfPresent(parameters.password, forKey: .password) + try container.encodeStringIfPresent(parameters.excerpt, forKey: .excerpt) + try container.encodeStringIfPresent(parameters.slug, forKey: .slug) + if let value = parameters.featuredImageID { + try container.encodeNullableID(value, forKey: .featuredImageID) + } + if let metadata = parameters.metadata, !metadata.isEmpty { + let metadata = metadata.map(RemotePostUpdateParametersWordPressComMetadata.init) + try container.encode(metadata, forKey: .metadata) + } + + // Pages + if let parentPageID = parameters.parentPageID { + try container.encodeNullableID(parentPageID, forKey: .parentPageID) + } + + // Posts + try container.encodeIfPresent(parameters.format, forKey: .format) + if let tags = parameters.tags { + try container.encode([RemotePostWordPressComCodingKeys.postTags: tags], forKey: .terms) + } + try container.encodeIfPresent(parameters.categoryIDs, forKey: .categoryIDs) + try container.encodeIfPresent(parameters.isSticky, forKey: .isSticky) + } +} + +// MARK: - Encoding (XML-RPC) + +private enum RemotePostXMLRPCCodingKeys: String, CodingKey { + case ifNotModifiedSince = "if_not_modified_since" + case type = "post_type" + case postStatus = "post_status" + case date = "post_date" + case authorID = "post_author" + case title = "post_title" + case content = "post_content" + case password = "post_password" + case excerpt = "post_excerpt" + case slug = "post_name" + case featuredImageID = "post_thumbnail" + case parentPageID = "post_parent" + case terms = "terms" + case termNames = "terms_names" + case format = "post_format" + case isSticky = "sticky" + case metadata = "custom_fields" + + static let taxonomyTag = "post_tag" + static let taxonomyCategory = "category" +} + +struct RemotePostCreateParametersXMLRPCEncoder: Encodable { + let parameters: RemotePostCreateParameters + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: RemotePostXMLRPCCodingKeys.self) + try container.encode(parameters.type, forKey: .type) + try container.encodeIfPresent(parameters.status, forKey: .postStatus) + try container.encodeIfPresent(parameters.date, forKey: .date) + try container.encodeIfPresent(parameters.authorID, forKey: .authorID) + try container.encodeIfPresent(parameters.title, forKey: .title) + try container.encodeIfPresent(parameters.content, forKey: .content) + try container.encodeIfPresent(parameters.password, forKey: .password) + try container.encodeIfPresent(parameters.excerpt, forKey: .excerpt) + try container.encodeIfPresent(parameters.slug, forKey: .slug) + try container.encodeIfPresent(parameters.featuredImageID, forKey: .featuredImageID) + if !parameters.metadata.isEmpty { + let metadata = parameters.metadata.map(RemotePostUpdateParametersXMLRPCMetadata.init) + try container.encode(metadata, forKey: .metadata) + } + + // Pages + try container.encodeIfPresent(parameters.parentPageID, forKey: .parentPageID) + + // Posts + try container.encodeIfPresent(parameters.format, forKey: .format) + if !parameters.tags.isEmpty { + try container.encode([RemotePostXMLRPCCodingKeys.taxonomyTag: parameters.tags], forKey: .termNames) + } + if !parameters.categoryIDs.isEmpty { + try container.encode([RemotePostXMLRPCCodingKeys.taxonomyCategory: parameters.categoryIDs], forKey: .terms) + } + if parameters.isSticky { + try container.encode(parameters.isSticky, forKey: .isSticky) + } + } +} + +struct RemotePostUpdateParametersXMLRPCEncoder: Encodable { + let parameters: RemotePostUpdateParameters + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: RemotePostXMLRPCCodingKeys.self) + try container.encodeIfPresent(parameters.ifNotModifiedSince, forKey: .ifNotModifiedSince) + try container.encodeIfPresent(parameters.status, forKey: .postStatus) + try container.encodeIfPresent(parameters.date, forKey: .date) + try container.encodeIfPresent(parameters.authorID, forKey: .authorID) + try container.encodeStringIfPresent(parameters.title, forKey: .title) + try container.encodeStringIfPresent(parameters.content, forKey: .content) + try container.encodeStringIfPresent(parameters.password, forKey: .password) + try container.encodeStringIfPresent(parameters.excerpt, forKey: .excerpt) + try container.encodeStringIfPresent(parameters.slug, forKey: .slug) + if let value = parameters.featuredImageID { + try container.encodeNullableID(value, forKey: .featuredImageID) + } + if let metadata = parameters.metadata, !metadata.isEmpty { + let metadata = metadata.map(RemotePostUpdateParametersXMLRPCMetadata.init) + try container.encode(metadata, forKey: .metadata) + } + + // Pages + if let parentPageID = parameters.parentPageID { + try container.encodeNullableID(parentPageID, forKey: .parentPageID) + } + + // Posts + try container.encodeStringIfPresent(parameters.format, forKey: .format) + if let tags = parameters.tags { + try container.encode([RemotePostXMLRPCCodingKeys.taxonomyTag: tags], forKey: .termNames) + } + if let categoryIDs = parameters.categoryIDs { + try container.encode([RemotePostXMLRPCCodingKeys.taxonomyCategory: categoryIDs], forKey: .terms) + } + try container.encodeIfPresent(parameters.isSticky, forKey: .isSticky) + } +} + +private struct RemotePostUpdateParametersXMLRPCMetadata: Encodable { + let id: String? + let key: String? + let value: String? + + init(_ item: RemotePostMetadataItem) { + self.id = item.id + self.key = item.key + self.value = item.value + } +} + +private extension KeyedEncodingContainer { + mutating func encodeStringIfPresent(_ value: String??, forKey key: Key) throws { + guard let value else { return } + try encode(value ?? "", forKey: key) + } + + /// - note: Some IDs are passed as integers, but, to reset them, you need to pass + /// an empty string (passing `nil` does not work) + mutating func encodeNullableID(_ value: Int?, forKey key: Key) throws { + if let value { + try encode(value, forKey: key) + } else { + try encode("", forKey: key) + } + } +} diff --git a/Modules/Sources/WordPressKit/RemoteProfile.swift b/Modules/Sources/WordPressKit/RemoteProfile.swift new file mode 100644 index 000000000000..b89e48ef6ba3 --- /dev/null +++ b/Modules/Sources/WordPressKit/RemoteProfile.swift @@ -0,0 +1,28 @@ +import Foundation + +public class RemoteProfile { + public let bio: String + public let displayName: String + public let email: String + public let firstName: String + public let lastName: String + public let nicename: String + public let nickname: String + public let url: String + public let userID: Int + public let username: String + + public init(dictionary: NSDictionary) { + bio = dictionary.string(forKey: "bio") ?? "" + displayName = dictionary.string(forKey: "display_name") ?? "" + email = dictionary.string(forKey: "email") ?? "" + firstName = dictionary.string(forKey: "first_name") ?? "" + lastName = dictionary.string(forKey: "last_name") ?? "" + nicename = dictionary.string(forKey: "nicename") ?? "" + nickname = dictionary.string(forKey: "nickname") ?? "" + url = dictionary.string(forKey: "url") ?? "" + userID = dictionary.number(forKey: "user_id")?.intValue ?? 0 + username = dictionary.string(forKey: "username") ?? "" + } + +} diff --git a/Modules/Sources/WordPressKit/RemotePublicizeConnection.swift b/Modules/Sources/WordPressKit/RemotePublicizeConnection.swift new file mode 100644 index 000000000000..76b49f5c75e7 --- /dev/null +++ b/Modules/Sources/WordPressKit/RemotePublicizeConnection.swift @@ -0,0 +1,22 @@ +import Foundation + +@objc open class RemotePublicizeConnection: NSObject { + @objc open var connectionID: NSNumber = 0 + @objc open var dateIssued = Date() + @objc open var dateExpires: Date? + @objc open var externalID = "" + @objc open var externalName = "" + @objc open var externalDisplay = "" + @objc open var externalProfilePicture = "" + @objc open var externalProfileURL = "" + @objc open var externalFollowerCount: NSNumber = 0 + @objc open var keyringConnectionID: NSNumber = 0 + @objc open var keyringConnectionUserID: NSNumber = 0 + @objc open var label = "" + @objc open var refreshURL = "" + @objc open var service = "" + @objc open var shared = false + @objc open var status = "" + @objc open var siteID: NSNumber = 0 + @objc open var userID: NSNumber = 0 +} diff --git a/Modules/Sources/WordPressKit/RemotePublicizeInfo.swift b/Modules/Sources/WordPressKit/RemotePublicizeInfo.swift new file mode 100644 index 000000000000..032fe371d042 --- /dev/null +++ b/Modules/Sources/WordPressKit/RemotePublicizeInfo.swift @@ -0,0 +1,15 @@ +import Foundation + +public struct RemotePublicizeInfo: Decodable { + public let shareLimit: Int + public let toBePublicizedCount: Int + public let sharedPostsCount: Int + public let sharesRemaining: Int + + private enum CodingKeys: CodingKey { + case shareLimit + case toBePublicizedCount + case sharedPostsCount + case sharesRemaining + } +} diff --git a/Modules/Sources/WordPressKit/RemotePublicizeService.swift b/Modules/Sources/WordPressKit/RemotePublicizeService.swift new file mode 100644 index 000000000000..2c0515642ec4 --- /dev/null +++ b/Modules/Sources/WordPressKit/RemotePublicizeService.swift @@ -0,0 +1,16 @@ +import Foundation + +@objc open class RemotePublicizeService: NSObject { + @objc open var connectURL = "" + @objc open var detail = "" + @objc open var externalUsersOnly = false + @objc open var icon = "" + @objc open var jetpackSupport = false + @objc open var jetpackModuleRequired = "" + @objc open var label = "" + @objc open var multipleExternalUserIDSupport = false + @objc open var order: NSNumber = 0 + @objc open var serviceID = "" + @objc open var type = "" + @objc open var status = "" +} diff --git a/Modules/Sources/WordPressKit/RemoteReaderCard.swift b/Modules/Sources/WordPressKit/RemoteReaderCard.swift new file mode 100644 index 000000000000..f37376ef83e4 --- /dev/null +++ b/Modules/Sources/WordPressKit/RemoteReaderCard.swift @@ -0,0 +1,57 @@ +import Foundation + +struct ReaderCardEnvelope: Decodable { + var cards: [RemoteReaderCard] + var nextPageHandle: String? + + private enum CodingKeys: String, CodingKey { + case cards + case nextPageHandle = "next_page_handle" + } +} + +public struct RemoteReaderCard: Decodable { + public enum CardType: String { + case post + case interests = "interests_you_may_like" + case sites = "recommended_blogs" + case unknown + } + + public var type: CardType + public var post: RemoteReaderPost? + public var interests: [RemoteReaderInterest]? + public var sites: [RemoteReaderSiteInfo]? + + private enum CodingKeys: String, CodingKey { + case type + case data + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let typeString = try container.decode(String.self, forKey: .type) + type = CardType(rawValue: typeString) ?? .unknown + + switch type { + case .post: + let postDictionary = try container.decode([String: Any].self, forKey: .data) + post = RemoteReaderPost(dictionary: postDictionary) + case .interests: + interests = try container.decode([RemoteReaderInterest].self, forKey: .data) + case .sites: + let sitesArray = try container.decode([Any].self, forKey: .data) + + sites = sitesArray.compactMap { + guard let dict = $0 as? NSDictionary else { + return nil + } + + return RemoteReaderSiteInfo.siteInfo(forSiteResponse: dict, isFeed: false) + } + + default: + post = nil + } + } +} diff --git a/Modules/Sources/WordPressKit/RemoteReaderInterest.swift b/Modules/Sources/WordPressKit/RemoteReaderInterest.swift new file mode 100644 index 000000000000..51f8d6c7f4b4 --- /dev/null +++ b/Modules/Sources/WordPressKit/RemoteReaderInterest.swift @@ -0,0 +1,21 @@ +import Foundation + +struct ReaderInterestEnvelope: Decodable { + var interests: [RemoteReaderInterest] +} + +public struct RemoteReaderInterest: Decodable { + public var title: String + public var slug: String + + private enum CodingKeys: String, CodingKey { + case title + case slug = "slug" + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + title = try container.decode(String.self, forKey: .title) + slug = try container.decode(String.self, forKey: .slug) + } +} diff --git a/Modules/Sources/WordPressKit/RemoteReaderPost.swift b/Modules/Sources/WordPressKit/RemoteReaderPost.swift new file mode 100644 index 000000000000..f3c9998972b1 --- /dev/null +++ b/Modules/Sources/WordPressKit/RemoteReaderPost.swift @@ -0,0 +1,17 @@ +import Foundation + +struct ReaderPostsEnvelope: Decodable { + var posts: [RemoteReaderPost] + var nextPageHandle: String? + + private enum CodingKeys: String, CodingKey { + case posts + case nextPageHandle = "next_page_handle" + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let postDictionary = try container.decode([String: Any].self, forKey: .posts) + posts = [RemoteReaderPost(dictionary: postDictionary)] + } +} diff --git a/Modules/Sources/WordPressKit/RemoteReaderSimplePost.swift b/Modules/Sources/WordPressKit/RemoteReaderSimplePost.swift new file mode 100644 index 000000000000..c8896ac8bc72 --- /dev/null +++ b/Modules/Sources/WordPressKit/RemoteReaderSimplePost.swift @@ -0,0 +1,66 @@ +import Foundation + +struct RemoteReaderSimplePostEnvelope: Decodable { + let posts: [RemoteReaderSimplePost] +} + +public struct RemoteReaderSimplePost: Decodable { + public enum PostType: Int { + case local + case global + case unknown + } + + public let postID: Int + public let postUrl: String + public let siteID: Int + public let isFollowing: Bool + public let title: String + public let author: RemoteReaderSimplePostAuthor + public let excerpt: String + public let siteName: String + public let featuredImageUrl: String? + public let featuredMedia: RemoteReaderSimplePostFeaturedMedia? + public let railcar: RemoteReaderSimplePostRailcar + + public var postType: PostType { + switch railcar.fetchAlgo { + case let algoStr where algoStr.contains("local"): + return .local + case let algoStr where algoStr.contains("global"): + return .global + default: + return .unknown + } + } + + private enum CodingKeys: String, CodingKey { + case postID = "ID" + case postUrl = "URL" + case siteID = "site_ID" + case isFollowing = "is_following" + case title + case author + case excerpt + case siteName = "site_name" + case featuredImageUrl = "featured_image" + case featuredMedia = "featured_media" + case railcar + } +} + +public struct RemoteReaderSimplePostAuthor: Decodable { + public let name: String +} + +public struct RemoteReaderSimplePostFeaturedMedia: Decodable { + public let uri: String? +} + +public struct RemoteReaderSimplePostRailcar: Decodable { + public let fetchAlgo: String + + private enum CodingKeys: String, CodingKey { + case fetchAlgo = "fetch_algo" + } +} diff --git a/Modules/Sources/WordPressKit/RemoteRevision.swift b/Modules/Sources/WordPressKit/RemoteRevision.swift new file mode 100644 index 000000000000..c57171aeba3e --- /dev/null +++ b/Modules/Sources/WordPressKit/RemoteRevision.swift @@ -0,0 +1,40 @@ +import Foundation + +/// Revision model +/// +public struct RemoteRevision: Codable { + /// Revision id + public var id: Int + + /// Optional post content + public var postContent: String? + + /// Optional post excerpt + public var postExcerpt: String? + + /// Optional post title + public var postTitle: String? + + /// Optional post date + public var postDateGmt: String? + + /// Optional post modified date + public var postModifiedGmt: String? + + /// Optional post author id + public var postAuthorId: String? + + /// Optional revision diff + public var diff: RemoteDiff? + + /// Mapping keys + private enum CodingKeys: String, CodingKey { + case id = "id" + case postContent = "post_content" + case postExcerpt = "post_excerpt" + case postTitle = "post_title" + case postDateGmt = "post_date_gmt" + case postModifiedGmt = "post_modified_gmt" + case postAuthorId = "post_author" + } +} diff --git a/Modules/Sources/WordPressKit/RemoteShareAppContent.swift b/Modules/Sources/WordPressKit/RemoteShareAppContent.swift new file mode 100644 index 000000000000..512ee42b3cbe --- /dev/null +++ b/Modules/Sources/WordPressKit/RemoteShareAppContent.swift @@ -0,0 +1,15 @@ +import Foundation +/// Defines the information structure used for recommending the app to others. +/// +public struct RemoteShareAppContent: Codable { + /// A text content to share. + public let message: String + + /// A URL string that directs the recipient to a page describing steps to get the app. + public let link: String + + /// Convenience method that returns `link` as URL. + public func linkURL() -> URL? { + URL(string: link) + } +} diff --git a/Modules/Sources/WordPressKit/RemoteSharingButton.swift b/Modules/Sources/WordPressKit/RemoteSharingButton.swift new file mode 100644 index 000000000000..1f3a7f6fb6e6 --- /dev/null +++ b/Modules/Sources/WordPressKit/RemoteSharingButton.swift @@ -0,0 +1,11 @@ +import Foundation + +@objc open class RemoteSharingButton: NSObject { + @objc open var buttonID = "" + @objc open var name = "" + @objc open var shortname = "" + @objc open var custom = false + @objc open var enabled = false + @objc open var visibility: String? + @objc open var order: NSNumber = 0 +} diff --git a/Modules/Sources/WordPressKit/RemoteSiteDesign.swift b/Modules/Sources/WordPressKit/RemoteSiteDesign.swift new file mode 100644 index 000000000000..594448c6274a --- /dev/null +++ b/Modules/Sources/WordPressKit/RemoteSiteDesign.swift @@ -0,0 +1,92 @@ +import Foundation + +public struct RemoteSiteDesigns: Codable { + public let designs: [RemoteSiteDesign] + public let categories: [RemoteSiteDesignCategory] + + enum CodingKeys: String, CodingKey { + case designs + case categories + } + + public init(from decoder: Decoder) throws { + let map = try decoder.container(keyedBy: CodingKeys.self) + designs = try map.decode([RemoteSiteDesign].self, forKey: .designs) + categories = try map.decode([RemoteSiteDesignCategory].self, forKey: .categories) + } + + public init() { + self.init(designs: [], categories: []) + } + + public init(designs: [RemoteSiteDesign], categories: [RemoteSiteDesignCategory]) { + self.designs = designs + self.categories = categories + } +} + +public struct RemoteSiteDesign: Codable { + public let slug: String + public let title: String + public let demoURL: String + public let screenshot: String? + public let mobileScreenshot: String? + public let tabletScreenshot: String? + public let themeSlug: String? + public let group: [String]? + public let segmentID: Int64? + public let categories: [RemoteSiteDesignCategory] + + enum CodingKeys: String, CodingKey { + case slug + case title + case demoURL = "demo_url" + case screenshot = "preview" + case mobileScreenshot = "preview_mobile" + case tabletScreenshot = "preview_tablet" + case themeSlug = "theme" + case group + case segmentID = "segment_id" + case categories + } + + public init(from decoder: Decoder) throws { + let map = try decoder.container(keyedBy: CodingKeys.self) + slug = try map.decode(String.self, forKey: .slug) + title = try map.decode(String.self, forKey: .title) + demoURL = try map.decode(String.self, forKey: .demoURL) + screenshot = try? map.decode(String.self, forKey: .screenshot) + mobileScreenshot = try? map.decode(String.self, forKey: .mobileScreenshot) + tabletScreenshot = try? map.decode(String.self, forKey: .tabletScreenshot) + themeSlug = try? map.decode(String.self, forKey: .themeSlug) + group = try? map.decode([String].self, forKey: .group) + segmentID = try? map.decode(Int64.self, forKey: .segmentID) + categories = try map.decode([RemoteSiteDesignCategory].self, forKey: .categories) + } +} + +public struct RemoteSiteDesignCategory: Codable, Comparable { + public static func < (lhs: RemoteSiteDesignCategory, rhs: RemoteSiteDesignCategory) -> Bool { + return lhs.slug < rhs.slug + } + + public let slug: String + public let title: String + public let description: String + public let emoji: String? + + enum CodingKeys: String, CodingKey { + case slug + case title + case description + case emoji + } + + public init(from decoder: Decoder) throws { + let map = try decoder.container(keyedBy: CodingKeys.self) + slug = try map.decode(String.self, forKey: .slug) + title = try map.decode(String.self, forKey: .title) + description = try map.decode(String.self, forKey: .description) + emoji = try? map.decode(String.self, forKey: .emoji) + } +} diff --git a/Modules/Sources/WordPressKit/RemoteWpcomPlan.swift b/Modules/Sources/WordPressKit/RemoteWpcomPlan.swift new file mode 100644 index 000000000000..75dbee2135f1 --- /dev/null +++ b/Modules/Sources/WordPressKit/RemoteWpcomPlan.swift @@ -0,0 +1,49 @@ +import Foundation + +public struct RemoteWpcomPlan { + // A commma separated list of groups to which the plan belongs. + public let groups: String + // A comma separated list of plan_ids described by the plan description, e.g. 1 year and 2 year plans. + public let products: String + // The full name of the plan. + public let name: String + // The shortened name of the plan. + public let shortname: String + // The plan's tagline. + public let tagline: String + // A description of the plan. + public let description: String + // A comma separated list of slugs for the plan's features. + public let features: String + // An icon representing the plan. + public let icon: String + // The plan priority in Zendesk + public let supportPriority: Int + // The name of the plan in Zendesk + public let supportName: String + // Non localized version of the shortened name + public let nonLocalizedShortname: String +} + +public struct RemotePlanGroup { + // A text slug identifying the group. + public let slug: String + // The name of the group. + public let name: String +} + +public struct RemotePlanFeature { + // A text slug identifying the plan feature. + public let slug: String + // The name/title of the feature. + public let title: String + // A description of the feature. + public let description: String + // Deprecated. An icon associeated with the feature. + public let iconURL: URL? +} + +public struct RemotePlanSimpleDescription { + public let planID: Int + public let name: String +} diff --git a/Modules/Sources/WordPressKit/Result+Callback.swift b/Modules/Sources/WordPressKit/Result+Callback.swift new file mode 100644 index 000000000000..6995d89ecbd2 --- /dev/null +++ b/Modules/Sources/WordPressKit/Result+Callback.swift @@ -0,0 +1,20 @@ +import Foundation +public extension Swift.Result { + + // Notice there are no explicit unit tests for this utility because it is implicitly tested via the consuming code's tests. + func execute(onSuccess: (Success) -> Void, onFailure: (Failure) -> Void) { + switch self { + case .success(let value): onSuccess(value) + case .failure(let error): onFailure(error) + } + } + + func execute(_ completion: (Self) -> Void) { + completion(self) + } + + func eraseToError() -> Result { + mapError { $0 } + } + +} diff --git a/Modules/Sources/WordPressKit/Secret.swift b/Modules/Sources/WordPressKit/Secret.swift new file mode 100644 index 000000000000..66b5a8f03390 --- /dev/null +++ b/Modules/Sources/WordPressKit/Secret.swift @@ -0,0 +1,49 @@ +import Foundation + +/// Wraps a value that contains sensitive information to prevent accidental logging +/// +/// Usage example +/// +/// ``` +/// let password = Secret("my secret password") +/// print(password) // Prints "--redacted--" +/// print(password.secretValue) // Prints "my secret password" +/// ``` +/// +public struct Secret { + public let secretValue: T + + public init(_ secretValue: T) { + self.secretValue = secretValue + } +} + +extension Secret: RawRepresentable { + public typealias RawValue = T + + public init?(rawValue: Self.RawValue) { + self.init(rawValue) + } + + public var rawValue: T { + return secretValue + } +} + +extension Secret: CustomStringConvertible, CustomDebugStringConvertible, CustomReflectable { + private static var redacted: String { + return "--redacted--" + } + + public var description: String { + return Secret.redacted + } + + public var debugDescription: String { + return Secret.redacted + } + + public var customMirror: Mirror { + return Mirror(reflecting: Secret.redacted) + } +} diff --git a/Modules/Sources/WordPressKit/SelfHostedPluginManagementClient.swift b/Modules/Sources/WordPressKit/SelfHostedPluginManagementClient.swift new file mode 100644 index 000000000000..917260315ae4 --- /dev/null +++ b/Modules/Sources/WordPressKit/SelfHostedPluginManagementClient.swift @@ -0,0 +1,163 @@ +import Foundation +public class SelfHostedPluginManagementClient: PluginManagementClient { + private let remote: WordPressOrgRestApi + + public required init?(with remote: WordPressOrgRestApi) { + self.remote = remote + } + + // MARK: - Get + public func getPlugins(success: @escaping (SitePlugins) -> Void, failure: @escaping (Error) -> Void) { + Task { @MainActor in + await remote.get(path: path(), type: [PluginStateResponse].self) + .mapError { error -> Error in + if case let .unparsableResponse(_, _, underlyingError) = error, underlyingError is DecodingError { + return PluginServiceRemote.ResponseError.decodingFailure + } + return error + } + .map { + SitePlugins( + plugins: $0.compactMap { self.pluginState(with: $0) }, + capabilities: SitePluginCapabilities(modify: true, autoupdate: false) + ) + } + .execute(onSuccess: success, onFailure: failure) + + } + } + + // MARK: - Activate / Deactivate + public func activatePlugin(pluginID: String, success: @escaping () -> Void, failure: @escaping (Error) -> Void) { + let parameters = ["status": "active"] + let path = self.path(with: pluginID) + Task { @MainActor in + await remote.perform(.put, path: path, parameters: parameters, type: AnyResponse.self) + .map { _ in } + .execute(onSuccess: success, onFailure: failure) + + } + } + + public func deactivatePlugin(pluginID: String, success: @escaping () -> Void, failure: @escaping (Error) -> Void) { + let parameters = ["status": "inactive"] + let path = self.path(with: pluginID) + Task { @MainActor in + await remote.perform(.put, path: path, parameters: parameters, type: AnyResponse.self) + .map { _ in } + .execute(onSuccess: success, onFailure: failure) + } + } + + // MARK: - Install / Uninstall + public func install(pluginSlug: String, success: @escaping (PluginState) -> Void, failure: @escaping (Error) -> Void) { + let parameters = ["slug": pluginSlug] + Task { @MainActor in + await remote.post(path: path(), parameters: parameters, type: PluginStateResponse.self) + .mapError { error -> Error in + if case let .unparsableResponse(_, _, underlyingError) = error, underlyingError is DecodingError { + return PluginServiceRemote.ResponseError.decodingFailure + } + return error + } + .flatMap { + guard let state = self.pluginState(with: $0) else { + return .failure(PluginServiceRemote.ResponseError.decodingFailure) + } + return .success(state) + } + .execute(onSuccess: success, onFailure: failure) + } + } + + public func remove(pluginID: String, success: @escaping () -> Void, failure: @escaping (Error) -> Void) { + let path = self.path(with: pluginID) + Task { @MainActor in + await remote.perform(.delete, path: path, type: AnyResponse.self) + .map { _ in } + .execute(onSuccess: success, onFailure: failure) + } + } + + // MARK: - Private: Helpers + private func path(with slug: String? = nil) -> String { + var returnPath = "wp/v2/plugins/" + + if let slug { + returnPath = returnPath.appending(slug) + } + + return returnPath + } + + private func pluginState(with response: PluginStateResponse) -> PluginState? { + guard + // The slugs returned are in the form of XXX/YYY + // The PluginStore uses slugs that are just XXX + // Extract that information out + let slug = response.plugin.components(separatedBy: "/").first + else { + return nil + } + + let isActive = response.status == "active" + + return PluginState(id: response.plugin, + slug: slug, + active: isActive, + name: response.name, + author: response.author, + version: response.version, + updateState: .updated, // API Doesn't support this yet + autoupdate: false, // API Doesn't support this yet + automanaged: false, // API Doesn't support this yet + // TODO: Return nil instead of an empty URL when 'plugin_uri' is nil? + url: URL(string: response.pluginURI ?? ""), + settingsURL: nil) + } + + // MARK: - Unsupported + public func updatePlugin(pluginID: String, success: @escaping (PluginState) -> Void, failure: @escaping (Error) -> Void) { + // NOOP - Not supported by the WP.org REST API + } + + public func enableAutoupdates(pluginID: String, success: @escaping () -> Void, failure: @escaping (Error) -> Void) { + // NOOP - Not supported by the WP.org REST API + + success() + } + + public func disableAutoupdates(pluginID: String, success: @escaping () -> Void, failure: @escaping (Error) -> Void) { + // NOOP - Not supported by the WP.org REST API + + success() + } + + public func activateAndEnableAutoupdates(pluginID: String, success: @escaping () -> Void, failure: @escaping (Error) -> Void) { + // Just activate since API does not support autoupdates yet + activatePlugin(pluginID: pluginID, success: success, failure: failure) + } +} + +private struct PluginStateResponse: Decodable { + enum CodingKeys: String, CodingKey { + case plugin = "plugin" + case status = "status" + case name = "name" + case author = "author" + case version = "version" + case pluginURI = "plugin_uri" + } + var plugin: String + var status: String + var name: String + var author: String + var version: String + var pluginURI: String? +} + +private struct AnyResponse: Decodable { + init(from decoder: Decoder) throws { + // Do nothing + } +} diff --git a/Modules/Sources/WordPressKit/ServiceRequest.swift b/Modules/Sources/WordPressKit/ServiceRequest.swift new file mode 100644 index 000000000000..99ea5afbb164 --- /dev/null +++ b/Modules/Sources/WordPressKit/ServiceRequest.swift @@ -0,0 +1,47 @@ +import Foundation + +/// Enumeration to identify a service action +enum ServiceRequestAction: String { + case subscribe = "new" + case unsubscribe = "delete" + case update = "update" +} + +/// A protocol for a Service request +protocol ServiceRequest { + /// Returns a valid url path + var path: String { get } + + /// Returns the used API version + var apiVersion: WordPressComRESTAPIVersion { get } +} + +/// Reader Topic Service request +enum ReaderTopicServiceSubscriptionsRequest { + case notifications(siteId: Int, action: ServiceRequestAction) + case postsEmail(siteId: Int, action: ServiceRequestAction) + case comments(siteId: Int, action: ServiceRequestAction) +} + +extension ReaderTopicServiceSubscriptionsRequest: ServiceRequest { + var apiVersion: WordPressComRESTAPIVersion { + switch self { + case .notifications: return ._2_0 + case .postsEmail: return ._1_2 + case .comments: return ._1_2 + } + } + + var path: String { + switch self { + case .notifications(let siteId, let action): + return "read/sites/\(siteId)/notification-subscriptions/\(action.rawValue)/" + + case .postsEmail(let siteId, let action): + return "read/site/\(siteId)/post_email_subscriptions/\(action.rawValue)/" + + case .comments(let siteId, let action): + return "read/site/\(siteId)/comment_email_subscriptions/\(action.rawValue)/" + } + } +} diff --git a/Modules/Sources/WordPressKit/SessionDetails.swift b/Modules/Sources/WordPressKit/SessionDetails.swift new file mode 100644 index 000000000000..1e259a6adb6c --- /dev/null +++ b/Modules/Sources/WordPressKit/SessionDetails.swift @@ -0,0 +1,36 @@ +import Foundation +public struct SessionDetails { + let deviceId: String + let platform: String + let buildNumber: String + let marketingVersion: String + let identifier: String + let osVersion: String +} + +extension SessionDetails: Encodable { + + enum CodingKeys: String, CodingKey { + case deviceId = "device_id" + case platform = "platform" + case buildNumber = "build_number" + case marketingVersion = "marketing_version" + case identifier = "identifier" + case osVersion = "os_version" + } + + init(deviceId: String, bundle: Bundle = .main) { + self.deviceId = deviceId + self.platform = "ios" + self.buildNumber = bundle.infoDictionary?["CFBundleVersion"] as? String ?? "Unknown" + self.marketingVersion = bundle.infoDictionary?["CFBundleShortVersionString"] as? String ?? "Unknown" + self.identifier = bundle.bundleIdentifier ?? "Unknown" + self.osVersion = UIDevice.current.systemVersion + } + + func dictionaryRepresentation() throws -> [String: AnyObject]? { + let encoder = JSONEncoder() + let data = try encoder.encode(self) + return try JSONSerialization.jsonObject(with: data) as? [String: AnyObject] + } +} diff --git a/Modules/Sources/WordPressKit/ShareAppContentServiceRemote.swift b/Modules/Sources/WordPressKit/ShareAppContentServiceRemote.swift new file mode 100644 index 000000000000..2bbc9fcf5f16 --- /dev/null +++ b/Modules/Sources/WordPressKit/ShareAppContentServiceRemote.swift @@ -0,0 +1,43 @@ +import Foundation +/// Encapsulates logic for fetching content to be shared by the user. +/// +open class ShareAppContentServiceRemote: ServiceRemoteWordPressComREST { + /// Fetch content to be shared by the user, based on the provided `appName`. + /// + /// - Parameters: + /// - appName: An enum that identifies the app to be shared. + /// - completion: A closure that will be called when the fetch request completes. + open func getContent(for appName: ShareAppName, completion: @escaping (Result) -> Void) { + let endpoint = "mobile/share-app-link" + let requestURLString = path(forEndpoint: endpoint, withVersion: ._2_0) + let params: [String: AnyObject] = [Constants.appNameParameterKey: appName.rawValue as AnyObject] + + Task { @MainActor in + await self.wordPressComRestApi + .perform( + .get, + URLString: requestURLString, + parameters: params, + jsonDecoder: .apiDecoder, + type: RemoteShareAppContent.self + ) + .map { $0.body } + .mapError { error -> Error in error.asNSError() } + .execute(completion) + } + } +} + +/// Defines a list of apps that can fetch share contents from the API. +public enum ShareAppName: String { + case wordpress + case jetpack +} + +// MARK: - Private Helpers + +private extension ShareAppContentServiceRemote { + struct Constants { + static let appNameParameterKey = "app" + } +} diff --git a/Modules/Sources/WordPressKit/SharingServiceRemote.swift b/Modules/Sources/WordPressKit/SharingServiceRemote.swift new file mode 100644 index 000000000000..a6bd0e6ebf1b --- /dev/null +++ b/Modules/Sources/WordPressKit/SharingServiceRemote.swift @@ -0,0 +1,606 @@ +import Foundation +import NSObject_SafeExpectations + +/// SharingServiceRemote is responsible for wrangling the REST API calls related to +/// publiczice services, publicize connections, and keyring connections. +/// +open class SharingServiceRemote: ServiceRemoteWordPressComREST { + + // MARK: - Helper methods + + /// Returns an error message to use is the API returns an unexpected result. + /// + /// - Parameter operation: The NSHTTPURLResponse that returned the unexpected result. + /// + /// - Returns: An `NSError` object. + /// + @objc func errorForUnexpectedResponse(_ httpResponse: HTTPURLResponse?) -> NSError { + let failureReason = "The request returned an unexpected type." + let domain = "org.wordpress.sharing-management" + let code = 0 + var urlString = "unknown" + if let unwrappedURL = httpResponse?.url?.absoluteString { + urlString = unwrappedURL + } + let userInfo = [ + "requestURL": urlString, + NSLocalizedDescriptionKey: failureReason, + NSLocalizedFailureReasonErrorKey: failureReason + ] + return NSError(domain: domain, code: code, userInfo: userInfo) + } + + // MARK: - Publicize Related Methods + + /// Fetches the list of Publicize services. + /// + /// - Parameters: + /// - success: An optional success block accepting an array of `RemotePublicizeService` objects. + /// - failure: An optional failure block accepting an `NSError` argument. + /// + @objc open func getPublicizeServices(_ success: (([RemotePublicizeService]) -> Void)?, failure: ((NSError?) -> Void)?) { + let endpoint = "meta/external-services" + let path = self.path(forEndpoint: endpoint, withVersion: ._1_1) + let params = ["type": "publicize"] + + wordPressComRESTAPI.get(path, parameters: params as [String: AnyObject]?) { responseObject, httpResponse in + guard let responseDict = responseObject as? NSDictionary else { + failure?(self.errorForUnexpectedResponse(httpResponse)) + return + } + + success?(self.remotePublicizeServicesFromDictionary(responseDict)) + + } failure: { error, _ in + failure?(error as NSError) + } + } + + /// Fetches the list of Publicize services for a specified siteID. + /// + /// - Parameters: + /// - siteID: The WordPress.com ID of the site. + /// - success: An optional success block accepting an array of `RemotePublicizeService` objects. + /// - failure: An optional failure block accepting an `NSError` argument. + @objc open func getPublicizeServices(for siteID: NSNumber, + success: (([RemotePublicizeService]) -> Void)?, + failure: ((NSError?) -> Void)?) { + let path = path(forEndpoint: "sites/\(siteID)/external-services", withVersion: ._2_0) + let params = ["type": "publicize" as AnyObject] + + wordPressComRESTAPI.get( + path, + parameters: params, + success: { response, httpResponse in + guard let responseDict = response as? NSDictionary else { + failure?(self.errorForUnexpectedResponse(httpResponse)) + return + } + + success?(self.remotePublicizeServicesFromDictionary(responseDict)) + }, + failure: { error, _ in + failure?(error as NSError) + } + ) + } + + /// Fetches the current user's list of keyring connections. + /// + /// - Parameters: + /// - success: An optional success block accepting an array of `KeyringConnection` objects. + /// - failure: An optional failure block accepting an `NSError` argument. + /// + @objc open func getKeyringConnections(_ success: (([KeyringConnection]) -> Void)?, failure: ((NSError?) -> Void)?) { + let endpoint = "me/keyring-connections" + let path = self.path(forEndpoint: endpoint, withVersion: ._1_1) + + wordPressComRESTAPI.get(path, + parameters: nil, + success: { responseObject, httpResponse in + guard let onSuccess = success else { + return + } + + guard let responseDict = responseObject as? NSDictionary else { + failure?(self.errorForUnexpectedResponse(httpResponse)) + return + } + + let connections = responseDict.array(forKey: ConnectionDictionaryKeys.connections) ?? [] + let keyringConnections: [KeyringConnection] = connections.map { (dict) -> KeyringConnection in + let conn = KeyringConnection() + let dict = dict as AnyObject + let externalUsers = dict.array(forKey: ConnectionDictionaryKeys.additionalExternalUsers) ?? [] + conn.additionalExternalUsers = self.externalUsersForKeyringConnection(externalUsers as NSArray) + conn.dateExpires = WPKitDateUtils.date(fromISOString: dict.string(forKey: ConnectionDictionaryKeys.expires)) + conn.dateIssued = WPKitDateUtils.date(fromISOString: dict.string(forKey: ConnectionDictionaryKeys.issued)) + conn.externalDisplay = dict.string(forKey: ConnectionDictionaryKeys.externalDisplay) ?? conn.externalDisplay + conn.externalID = dict.string(forKey: ConnectionDictionaryKeys.externalID) ?? conn.externalID + conn.externalName = dict.string(forKey: ConnectionDictionaryKeys.externalName) ?? conn.externalName + conn.externalProfilePicture = dict.string(forKey: ConnectionDictionaryKeys.externalProfilePicture) ?? conn.externalProfilePicture + conn.keyringID = dict.number(forKey: ConnectionDictionaryKeys.ID) ?? conn.keyringID + conn.label = dict.string(forKey: ConnectionDictionaryKeys.label) ?? conn.label + conn.refreshURL = dict.string(forKey: ConnectionDictionaryKeys.refreshURL) ?? conn.refreshURL + conn.status = dict.string(forKey: ConnectionDictionaryKeys.status) ?? conn.status + conn.service = dict.string(forKey: ConnectionDictionaryKeys.service) ?? conn.service + conn.type = dict.string(forKey: ConnectionDictionaryKeys.type) ?? conn.type + conn.userID = dict.number(forKey: ConnectionDictionaryKeys.userID) ?? conn.userID + + return conn + } + + onSuccess(keyringConnections) + }, + failure: { error, _ in + failure?(error as NSError) + }) + } + + /// Creates KeyringConnectionExternalUser instances from the past array of + /// external user dictionaries. + /// + /// - Parameters: + /// - externalUsers: An array of NSDictionaries where each NSDictionary represents a KeyringConnectionExternalUser + /// + /// - Returns: An array of KeyringConnectionExternalUser instances. + /// + private func externalUsersForKeyringConnection(_ externalUsers: NSArray) -> [KeyringConnectionExternalUser] { + let arr: [KeyringConnectionExternalUser] = externalUsers.map { (dict) -> KeyringConnectionExternalUser in + let externalUser = KeyringConnectionExternalUser() + externalUser.externalID = (dict as AnyObject).string(forKey: ConnectionDictionaryKeys.externalID) ?? externalUser.externalID + externalUser.externalName = (dict as AnyObject).string(forKey: ConnectionDictionaryKeys.externalName) ?? externalUser.externalName + externalUser.externalProfilePicture = (dict as AnyObject).string(forKey: ConnectionDictionaryKeys.externalProfilePicture) ?? externalUser.externalProfilePicture + externalUser.externalCategory = (dict as AnyObject).string(forKey: ConnectionDictionaryKeys.externalCategory) ?? externalUser.externalCategory + + return externalUser + } + return arr + } + + /// Fetches the current user's list of Publicize connections for the specified site's ID. + /// + /// - Parameters: + /// - siteID: The WordPress.com ID of the site. + /// - success: An optional success block accepting an array of `RemotePublicizeConnection` objects. + /// - failure: An optional failure block accepting an `NSError` argument. + /// + @objc open func getPublicizeConnections(_ siteID: NSNumber, success: (([RemotePublicizeConnection]) -> Void)?, failure: ((NSError?) -> Void)?) { + let endpoint = "sites/\(siteID)/publicize-connections" + let path = self.path(forEndpoint: endpoint, withVersion: ._1_1) + + wordPressComRESTAPI.get(path, + parameters: nil, + success: { responseObject, httpResponse in + guard let onSuccess = success else { + return + } + + guard let responseDict = responseObject as? NSDictionary else { + failure?(self.errorForUnexpectedResponse(httpResponse)) + return + } + + let connections = responseDict.array(forKey: ConnectionDictionaryKeys.connections) ?? [] + let publicizeConnections: [RemotePublicizeConnection] = connections.compactMap { (dict) -> RemotePublicizeConnection? in + let conn = self.remotePublicizeConnectionFromDictionary(dict as! NSDictionary) + return conn + } + + onSuccess(publicizeConnections) + }, + failure: { error, _ in + failure?(error as NSError) + }) + } + + /// Create a new Publicize connection bweteen the specified blog and + /// the third-pary service represented by the keyring. + /// + /// - Parameters: + /// - siteID: The WordPress.com ID of the site. + /// - keyringConnectionID: The ID of the third-party site's keyring connection. + /// - success: An optional success block accepting a `RemotePublicizeConnection` object. + /// - failure: An optional failure block accepting an `NSError` argument. + /// + @objc open func createPublicizeConnection(_ siteID: NSNumber, + keyringConnectionID: NSNumber, + externalUserID: String?, + success: ((RemotePublicizeConnection) -> Void)?, + failure: ((NSError) -> Void)?) { + + let endpoint = "sites/\(siteID)/publicize-connections/new" + let path = self.path(forEndpoint: endpoint, withVersion: ._1_1) + + var parameters: [String: AnyObject] = [PublicizeConnectionParams.keyringConnectionID: keyringConnectionID] + if let userID = externalUserID { + parameters[PublicizeConnectionParams.externalUserID] = userID as AnyObject? + } + + wordPressComRESTAPI.post(path, + parameters: parameters, + success: { responseObject, httpResponse in + guard let onSuccess = success else { + return + } + + guard let responseDict = responseObject as? NSDictionary, + let conn = self.remotePublicizeConnectionFromDictionary(responseDict) else { + failure?(self.errorForUnexpectedResponse(httpResponse)) + return + } + + onSuccess(conn) + }, + failure: { error, _ in + failure?(error as NSError) + }) + } + + /// Update the shared status of the specified publicize connection + /// + /// - Parameters: + /// - connectionID: The ID of the publicize connection. + /// - externalID: The connection's externalID. Pass `nil` if the keyring + /// connection's default external ID should be used. Otherwise pass the external + /// ID of one if the keyring connection's `additionalExternalUsers`. + /// - siteID: The WordPress.com ID of the site. + /// - success: An optional success block accepting no arguments. + /// - failure: An optional failure block accepting an `NSError` argument. + /// + @objc open func updatePublicizeConnectionWithID(_ connectionID: NSNumber, + externalID: String?, + forSite siteID: NSNumber, + success: ((RemotePublicizeConnection) -> Void)?, + failure: ((NSError?) -> Void)?) { + let endpoint = "sites/\(siteID)/publicize-connections/\(connectionID)" + let path = self.path(forEndpoint: endpoint, withVersion: ._1_1) + let externalUserID = (externalID == nil) ? "false" : externalID! + + let parameters = [ + PublicizeConnectionParams.externalUserID: externalUserID + ] + + wordPressComRESTAPI.post(path, + parameters: parameters as [String: AnyObject]?, + success: { responseObject, httpResponse in + guard let onSuccess = success else { + return + } + + guard let responseDict = responseObject as? NSDictionary, + let conn = self.remotePublicizeConnectionFromDictionary(responseDict) else { + failure?(self.errorForUnexpectedResponse(httpResponse)) + return + } + + onSuccess(conn) + }, + failure: { error, _ in + failure?(error as NSError) + }) + } + + /// Update the shared status of the specified publicize connection + /// + /// - Parameters: + /// - connectionID: The ID of the publicize connection. + /// - shared: True if the connection is shared with all users of the blog. False otherwise. + /// - siteID: The WordPress.com ID of the site. + /// - success: An optional success block accepting no arguments. + /// - failure: An optional failure block accepting an `NSError` argument. + /// + @objc open func updatePublicizeConnectionWithID(_ connectionID: NSNumber, + shared: Bool, + forSite siteID: NSNumber, + success: ((RemotePublicizeConnection) -> Void)?, + failure: ((NSError?) -> Void)?) { + let endpoint = "sites/\(siteID)/publicize-connections/\(connectionID)" + let path = self.path(forEndpoint: endpoint, withVersion: ._1_1) + let parameters = [ + PublicizeConnectionParams.shared: shared + ] + + wordPressComRESTAPI.post(path, + parameters: parameters as [String: AnyObject]?, + success: { responseObject, httpResponse in + guard let onSuccess = success else { + return + } + + guard let responseDict = responseObject as? NSDictionary, + let conn = self.remotePublicizeConnectionFromDictionary(responseDict) else { + failure?(self.errorForUnexpectedResponse(httpResponse)) + return + } + + onSuccess(conn) + }, + failure: { error, _ in + failure?(error as NSError) + }) + } + + /// Disconnects (deletes) the specified publicize connection + /// + /// - Parameters: + /// - siteID: The WordPress.com ID of the site. + /// - connectionID: The ID of the publicize connection. + /// - success: An optional success block accepting no arguments. + /// - failure: An optional failure block accepting an `NSError` argument. + /// + @objc open func deletePublicizeConnection(_ siteID: NSNumber, connectionID: NSNumber, success: (() -> Void)?, failure: ((NSError?) -> Void)?) { + let endpoint = "sites/\(siteID)/publicize-connections/\(connectionID)/delete" + let path = self.path(forEndpoint: endpoint, withVersion: ._1_1) + + wordPressComRESTAPI.post(path, + parameters: nil, + success: { _, _ in + success?() + }, + failure: { error, _ in + failure?(error as NSError) + }) + } + + /// Composees a `RemotePublicizeConnection` populated with values from the passed `NSDictionary` + /// + /// - Parameter dict: An `NSDictionary` representing a `RemotePublicizeConnection`. + /// + /// - Returns: A `RemotePublicizeConnection` object. + /// + private func remotePublicizeConnectionFromDictionary(_ dict: NSDictionary) -> RemotePublicizeConnection? { + guard let connectionID = dict.number(forKey: ConnectionDictionaryKeys.ID) else { + return nil + } + + let conn = RemotePublicizeConnection() + conn.connectionID = connectionID + conn.externalDisplay = dict.string(forKey: ConnectionDictionaryKeys.externalDisplay) ?? conn.externalDisplay + conn.externalID = dict.string(forKey: ConnectionDictionaryKeys.externalID) ?? conn.externalID + conn.externalName = dict.string(forKey: ConnectionDictionaryKeys.externalName) ?? conn.externalName + conn.externalProfilePicture = dict.string(forKey: ConnectionDictionaryKeys.externalProfilePicture) ?? conn.externalProfilePicture + conn.externalProfileURL = dict.string(forKey: ConnectionDictionaryKeys.externalProfileURL) ?? conn.externalProfileURL + conn.keyringConnectionID = dict.number(forKey: ConnectionDictionaryKeys.keyringConnectionID) ?? conn.keyringConnectionID + conn.keyringConnectionUserID = dict.number(forKey: ConnectionDictionaryKeys.keyringConnectionUserID) ?? conn.keyringConnectionUserID + conn.label = dict.string(forKey: ConnectionDictionaryKeys.label) ?? conn.label + conn.refreshURL = dict.string(forKey: ConnectionDictionaryKeys.refreshURL) ?? conn.refreshURL + conn.status = dict.string(forKey: ConnectionDictionaryKeys.status) ?? conn.status + conn.service = dict.string(forKey: ConnectionDictionaryKeys.service) ?? conn.service + + if let expirationDateAsString = dict.string(forKey: ConnectionDictionaryKeys.expires) { + conn.dateExpires = WPKitDateUtils.date(fromISOString: expirationDateAsString) + } + + if let issueDateAsString = dict.string(forKey: ConnectionDictionaryKeys.issued) { + conn.dateIssued = WPKitDateUtils.date(fromISOString: issueDateAsString) + } + + if let sharedDictNumber = dict.number(forKey: ConnectionDictionaryKeys.shared) { + conn.shared = sharedDictNumber.boolValue + } + + if let siteIDDictNumber = dict.number(forKey: ConnectionDictionaryKeys.siteID) { + conn.siteID = siteIDDictNumber + } + + if let userIDDictNumber = dict.number(forKey: ConnectionDictionaryKeys.userID) { + conn.userID = userIDDictNumber + } + + return conn + } + + // MARK: - Sharing Button Related Methods + + /// Fetches the list of sharing buttons for a blog. + /// + /// - Parameters: + /// - siteID: The WordPress.com ID of the site. + /// - success: An optional success block accepting an array of `RemoteSharingButton` objects. + /// - failure: An optional failure block accepting an `NSError` argument. + /// + @objc open func getSharingButtonsForSite(_ siteID: NSNumber, success: (([RemoteSharingButton]) -> Void)?, failure: ((NSError?) -> Void)?) { + let endpoint = "sites/\(siteID)/sharing-buttons" + let path = self.path(forEndpoint: endpoint, withVersion: ._1_1) + + wordPressComRESTAPI.get(path, + parameters: nil, + success: { responseObject, httpResponse in + guard let onSuccess = success else { + return + } + + guard let responseDict = responseObject as? NSDictionary else { + failure?(self.errorForUnexpectedResponse(httpResponse)) + return + } + + let buttons = responseDict.array(forKey: SharingButtonsKeys.sharingButtons) as? NSArray ?? NSArray() + let sharingButtons = self.remoteSharingButtonsFromDictionary(buttons) + + onSuccess(sharingButtons) + }, + failure: { error, _ in + failure?(error as NSError) + }) + } + + /// Updates the list of sharing buttons for a blog. + /// + /// - Parameters: + /// - siteID: The WordPress.com ID of the site. + /// - sharingButtons: The list of sharing buttons to update. Should be the full list and in the desired order. + /// - success: An optional success block accepting an array of `RemoteSharingButton` objects. + /// - failure: An optional failure block accepting an `NSError` argument. + /// + @objc open func updateSharingButtonsForSite(_ siteID: NSNumber, sharingButtons: [RemoteSharingButton], success: (([RemoteSharingButton]) -> Void)?, failure: ((NSError?) -> Void)?) { + let endpoint = "sites/\(siteID)/sharing-buttons" + let path = self.path(forEndpoint: endpoint, withVersion: ._1_1) + let buttons = dictionariesFromRemoteSharingButtons(sharingButtons) + let parameters = [SharingButtonsKeys.sharingButtons: buttons] + + wordPressComRESTAPI.post(path, + parameters: parameters as [String: AnyObject]?, + success: { responseObject, httpResponse in + guard let onSuccess = success else { + return + } + + guard let responseDict = responseObject as? NSDictionary else { + failure?(self.errorForUnexpectedResponse(httpResponse)) + return + } + + let buttons = responseDict.array(forKey: SharingButtonsKeys.updated) as? NSArray ?? NSArray() + let sharingButtons = self.remoteSharingButtonsFromDictionary(buttons) + + onSuccess(sharingButtons) + }, + failure: { error, _ in + failure?(error as NSError) + }) + } + + /// Composees a `RemotePublicizeConnection` populated with values from the passed `NSDictionary` + /// + /// - Parameter buttons: An `NSArray` of `NSDictionary`s representing `RemoteSharingButton` objects. + /// + /// - Returns: An array of `RemoteSharingButton` objects. + /// + private func remoteSharingButtonsFromDictionary(_ buttons: NSArray) -> [RemoteSharingButton] { + var order = 0 + let sharingButtons: [RemoteSharingButton] = buttons.map { (dict) -> RemoteSharingButton in + let btn = RemoteSharingButton() + btn.buttonID = (dict as AnyObject).string(forKey: SharingButtonsKeys.buttonID) ?? btn.buttonID + btn.name = (dict as AnyObject).string(forKey: SharingButtonsKeys.name) ?? btn.name + btn.shortname = (dict as AnyObject).string(forKey: SharingButtonsKeys.shortname) ?? btn.shortname + if let customDictNumber = (dict as AnyObject).number(forKey: SharingButtonsKeys.custom) { + btn.custom = customDictNumber.boolValue + } + if let enabledDictNumber = (dict as AnyObject).number(forKey: SharingButtonsKeys.enabled) { + btn.enabled = enabledDictNumber.boolValue + } + btn.visibility = (dict as AnyObject).string(forKey: SharingButtonsKeys.visibility) ?? btn.visibility + btn.order = NSNumber(value: order) + order += 1 + + return btn + } + + return sharingButtons + } + + private func dictionariesFromRemoteSharingButtons(_ buttons: [RemoteSharingButton]) -> [NSDictionary] { + return buttons.map({ (btn) -> NSDictionary in + + let dict = NSMutableDictionary() + dict[SharingButtonsKeys.buttonID] = btn.buttonID + dict[SharingButtonsKeys.name] = btn.name + dict[SharingButtonsKeys.shortname] = btn.shortname + dict[SharingButtonsKeys.custom] = btn.custom + dict[SharingButtonsKeys.enabled] = btn.enabled + if let visibility = btn.visibility { + dict[SharingButtonsKeys.visibility] = visibility + } + + return dict + }) + } + + private func remotePublicizeServicesFromDictionary(_ dictionary: NSDictionary) -> [RemotePublicizeService] { + let responseString = dictionary.description as NSString + let services: NSDictionary = (dictionary.forKey(ServiceDictionaryKeys.services) as? NSDictionary) ?? NSDictionary() + + return services.allKeys.map { key in + let dict = (services.forKey(key) as? NSDictionary) ?? NSDictionary() + let pub = RemotePublicizeService() + + pub.connectURL = dict.string(forKey: ServiceDictionaryKeys.connectURL) ?? "" + pub.detail = dict.string(forKey: ServiceDictionaryKeys.description) ?? "" + pub.externalUsersOnly = dict.number(forKey: ServiceDictionaryKeys.externalUsersOnly)?.boolValue ?? false + pub.icon = dict.string(forKey: ServiceDictionaryKeys.icon) ?? "" + pub.serviceID = dict.string(forKey: ServiceDictionaryKeys.ID) ?? "" + pub.jetpackModuleRequired = dict.string(forKey: ServiceDictionaryKeys.jetpackModuleRequired) ?? "" + pub.jetpackSupport = dict.number(forKey: ServiceDictionaryKeys.jetpackSupport)?.boolValue ?? false + pub.label = dict.string(forKey: ServiceDictionaryKeys.label) ?? "" + pub.multipleExternalUserIDSupport = dict.number(forKey: ServiceDictionaryKeys.multipleExternalUserIDSupport)?.boolValue ?? false + pub.type = dict.string(forKey: ServiceDictionaryKeys.type) ?? "" + pub.status = dict.string(forKey: ServiceDictionaryKeys.status) ?? "" + + // We're not guarenteed to get the right order by inspecting the + // response dictionary's keys. Instead, we can check the index + // of each service in the response string. + pub.order = NSNumber(value: responseString.range(of: pub.serviceID).location) + + return pub + } + } +} + +// Keys for PublicizeService dictionaries +private struct ServiceDictionaryKeys { + static let connectURL = "connect_URL" + static let description = "description" + static let externalUsersOnly = "external_users_only" + static let ID = "ID" + static let icon = "icon" + static let jetpackModuleRequired = "jetpack_module_required" + static let jetpackSupport = "jetpack_support" + static let label = "label" + static let multipleExternalUserIDSupport = "multiple_external_user_ID_support" + static let services = "services" + static let type = "type" + static let status = "status" +} + +// Keys for both KeyringConnection and PublicizeConnection dictionaries +private struct ConnectionDictionaryKeys { + // shared keys + static let connections = "connections" + static let expires = "expires" + static let externalID = "external_ID" + static let externalName = "external_name" + static let externalDisplay = "external_display" + static let externalProfilePicture = "external_profile_picture" + static let issued = "issued" + static let ID = "ID" + static let label = "label" + static let refreshURL = "refresh_URL" + static let service = "service" + static let sites = "sites" + static let status = "status" + static let userID = "user_ID" + + // only KeyringConnections + static let additionalExternalUsers = "additional_external_users" + static let type = "type" + static let externalCategory = "external_category" + + // only PublicizeConnections + static let externalFollowerCount = "external_follower_count" + static let externalProfileURL = "external_profile_URL" + static let keyringConnectionID = "keyring_connection_ID" + static let keyringConnectionUserID = "keyring_connection_user_ID" + static let shared = "shared" + static let siteID = "site_ID" +} + +// Names of parameters passed when creating or updating a publicize connection +private struct PublicizeConnectionParams { + static let keyringConnectionID = "keyring_connection_ID" + static let externalUserID = "external_user_ID" + static let shared = "shared" +} + +// Names of parameters used in SharingButton requests +private struct SharingButtonsKeys { + static let sharingButtons = "sharing_buttons" + static let buttonID = "ID" + static let name = "name" + static let shortname = "shortname" + static let custom = "custom" + static let enabled = "enabled" + static let visibility = "visibility" + static let updated = "updated" +} diff --git a/Modules/Sources/WordPressKit/SiteDesignServiceRemote.swift b/Modules/Sources/WordPressKit/SiteDesignServiceRemote.swift new file mode 100644 index 000000000000..2a0f31b71a9e --- /dev/null +++ b/Modules/Sources/WordPressKit/SiteDesignServiceRemote.swift @@ -0,0 +1,62 @@ +import Foundation + +public struct SiteDesignRequest { + public enum TemplateGroup: String { + case stable + case beta + case singlePage = "single-page" + } + + public let parameters: [String: AnyObject] + + public init(withThumbnailSize thumbnailSize: CGSize, withGroups groups: [TemplateGroup] = []) { + var parameters: [String: AnyObject] + parameters = [ + "preview_width": "\(thumbnailSize.width)" as AnyObject, + "preview_height": "\(thumbnailSize.height)" as AnyObject, + "scale": UIScreen.main.nativeScale as AnyObject + ] + if 0 < groups.count { + let groups = groups.map { $0.rawValue } + parameters["group"] = groups.joined(separator: ",") as AnyObject + } + self.parameters = parameters + } +} + +public class SiteDesignServiceRemote { + + public typealias CompletionHandler = (Swift.Result) -> Void + + static let endpoint = "/wpcom/v2/common-starter-site-designs" + static let parameters: [String: AnyObject] = [ + "type": ("mobile" as AnyObject) + ] + + private static func joinParameters(_ parameters: [String: AnyObject], additionalParameters: [String: AnyObject]?) -> [String: AnyObject] { + guard let additionalParameters else { return parameters } + return parameters.reduce(into: additionalParameters, { (result, element) in + result[element.key] = element.value + }) + } + + public static func fetchSiteDesigns(_ api: WordPressComRestApi, request: SiteDesignRequest? = nil, completion: @escaping CompletionHandler) { + let combinedParameters: [String: AnyObject] = joinParameters(parameters, additionalParameters: request?.parameters) + api.GET(endpoint, parameters: combinedParameters, success: { (responseObject, _) in + do { + let result = try parseLayouts(fromResponse: responseObject) + completion(.success(result)) + } catch let error { + NSLog("error response object: %@", String(describing: responseObject)) + completion(.failure(error)) + } + }, failure: { (error, _) in + completion(.failure(error)) + }) + } + + private static func parseLayouts(fromResponse response: Any) throws -> RemoteSiteDesigns { + let data = try JSONSerialization.data(withJSONObject: response) + return try JSONDecoder().decode(RemoteSiteDesigns.self, from: data) + } +} diff --git a/Modules/Sources/WordPressKit/SiteManagementServiceRemote.swift b/Modules/Sources/WordPressKit/SiteManagementServiceRemote.swift new file mode 100644 index 000000000000..8a639b08c3ba --- /dev/null +++ b/Modules/Sources/WordPressKit/SiteManagementServiceRemote.swift @@ -0,0 +1,170 @@ +import Foundation +import WordPressKitObjC + +/// SiteManagementServiceRemote handles REST API calls for managing a WordPress.com site. +/// +open class SiteManagementServiceRemote: ServiceRemoteWordPressComREST { + /// Deletes the specified WordPress.com site. + /// + /// - Parameters: + /// - siteID: The WordPress.com ID of the site. + /// - success: Optional success block with no parameters + /// - failure: Optional failure block with NSError + /// + @objc open func deleteSite(_ siteID: NSNumber, success: (() -> Void)?, failure: ((NSError) -> Void)?) { + let endpoint = "sites/\(siteID)/delete" + let path = self.path(forEndpoint: endpoint, withVersion: ._1_1) + + wordPressComRESTAPI.post(path, + parameters: nil, + success: { response, _ in + guard let results = response as? [String: AnyObject] else { + failure?(SiteError.deleteInvalidResponse.toNSError()) + return + } + guard let status = results[ResultKey.Status] as? String else { + failure?(SiteError.deleteMissingStatus.toNSError()) + return + } + guard status == ResultValue.Deleted else { + failure?(SiteError.deleteFailed.toNSError()) + return + } + + success?() + }, + failure: { error, _ in + failure?(error as NSError) + }) + } + + /// Triggers content export of the specified WordPress.com site. + /// + /// - Note: An email will be sent with download link when export completes. + /// + /// - Parameters: + /// - siteID: The WordPress.com ID of the site. + /// - success: Optional success block with no parameters + /// - failure: Optional failure block with NSError + /// + @objc open func exportContent(_ siteID: NSNumber, success: (() -> Void)?, failure: ((NSError) -> Void)?) { + let endpoint = "sites/\(siteID)/exports/start" + let path = self.path(forEndpoint: endpoint, withVersion: ._1_1) + + wordPressComRESTAPI.post(path, + parameters: nil, + success: { response, _ in + guard let results = response as? [String: AnyObject] else { + failure?(SiteError.exportInvalidResponse.toNSError()) + return + } + guard let status = results[ResultKey.Status] as? String else { + failure?(SiteError.exportMissingStatus.toNSError()) + return + } + guard status == ResultValue.Running else { + failure?(SiteError.exportFailed.toNSError()) + return + } + + success?() + }, + failure: { error, _ in + failure?(error as NSError) + }) + } + + /// Gets the list of active purchases of the specified WordPress.com site. + /// + /// - Parameters: + /// - siteID: The WordPress.com ID of the site. + /// - success: Optional success block with array of purchases (if any) + /// - failure: Optional failure block with NSError + /// + @objc open func getActivePurchases(_ siteID: NSNumber, success: (([SitePurchase]) -> Void)?, failure: ((NSError) -> Void)?) { + let endpoint = "sites/\(siteID)/purchases" + let path = self.path(forEndpoint: endpoint, withVersion: ._1_1) + + wordPressComRESTAPI.get(path, + parameters: nil, + success: { response, _ in + guard let results = response as? [SitePurchase] else { + failure?(SiteError.purchasesInvalidResponse.toNSError()) + return + } + + let actives = results.filter { $0[ResultKey.Active]?.boolValue == true } + success?(actives) + }, + failure: { error, _ in + failure?(error as NSError) + }) + } + + /// Trigger a masterbar notification celebrating completion of mobile quick start. + /// + /// - Parameters: + /// - siteID: The WordPress.com ID of the site. + /// - success: Optional success block + /// - failure: Optional failure block with NSError + /// + @objc open func markQuickStartChecklistAsComplete(_ siteID: NSNumber, success: (() -> Void)?, failure: ((NSError) -> Void)?) { + let endpoint = "sites/\(siteID)/mobile-quick-start" + let path = self.path(forEndpoint: endpoint, withVersion: ._1_1) + let parameters = ["variant": "next-steps"] as [String: AnyObject] + + wordPressComRESTAPI.post(path, + parameters: parameters, + success: { _, _ in + success?() + }, + failure: { error, _ in + failure?(error as NSError) + }) + } + + /// Keys found in API results + /// + private struct ResultKey { + static let Status = "status" + static let Active = "active" + } + + /// Values found in API results + /// + private struct ResultValue { + static let Deleted = "deleted" + static let Running = "running" + } + + /// Errors generated by this class whilst parsing API results + /// + enum SiteError: Error, CustomStringConvertible { + case deleteInvalidResponse + case deleteMissingStatus + case deleteFailed + case exportInvalidResponse + case exportMissingStatus + case exportFailed + case purchasesInvalidResponse + + var description: String { + switch self { + case .deleteInvalidResponse, .deleteMissingStatus, .deleteFailed: + return NSLocalizedString("The site could not be deleted.", comment: "Message shown when site deletion API failed") + case .exportInvalidResponse, .exportMissingStatus, .exportFailed: + return NSLocalizedString("The site could not be exported.", comment: "Message shown when site export API failed") + case .purchasesInvalidResponse: + return NSLocalizedString("Could not check site purchases.", comment: "Message shown when site purchases API failed") + } + } + + func toNSError() -> NSError { + return NSError(domain: _domain, code: _code, userInfo: [NSLocalizedDescriptionKey: String(describing: self)]) + } + } +} + +/// Returned in array from /purchases endpoint +/// +public typealias SitePurchase = [String: AnyObject] diff --git a/Modules/Sources/WordPressKit/SitePlugin.swift b/Modules/Sources/WordPressKit/SitePlugin.swift new file mode 100644 index 000000000000..df35faaab00d --- /dev/null +++ b/Modules/Sources/WordPressKit/SitePlugin.swift @@ -0,0 +1,7 @@ +import Foundation + +public struct SitePlugins: Codable { + public var plugins: [PluginState] + public var capabilities: SitePluginCapabilities + +} diff --git a/Modules/Sources/WordPressKit/SitePluginCapabilities.swift b/Modules/Sources/WordPressKit/SitePluginCapabilities.swift new file mode 100644 index 000000000000..60f828094df7 --- /dev/null +++ b/Modules/Sources/WordPressKit/SitePluginCapabilities.swift @@ -0,0 +1,11 @@ +import Foundation + +public struct SitePluginCapabilities: Equatable, Codable { + public let modify: Bool + public let autoupdate: Bool + + public static func ==(lhs: SitePluginCapabilities, rhs: SitePluginCapabilities) -> Bool { + return lhs.modify == rhs.modify + && lhs.autoupdate == rhs.autoupdate + } +} diff --git a/Modules/Sources/WordPressKit/SocialLogin2FANonceInfo.swift b/Modules/Sources/WordPressKit/SocialLogin2FANonceInfo.swift new file mode 100644 index 000000000000..6f3e508d1ecc --- /dev/null +++ b/Modules/Sources/WordPressKit/SocialLogin2FANonceInfo.swift @@ -0,0 +1,56 @@ +import Foundation + +@objc +/// This type is not only used for social logins, but we have not renamed it to maintain compatibility. +/// +public class SocialLogin2FANonceInfo: NSObject { + @objc public var nonceSMS = "" + @objc public var nonceWebauthn = "" + @objc var nonceBackup = "" + @objc var nonceAuthenticator = "" + @objc var supportedAuthTypes = [String]() // backup|authenticator|sms|webauthn + @objc var notificationSent = "" // none|sms + @objc var phoneNumber = "" // The last two digits of the phone number to which an SMS was sent. + + private enum Constants { + static let lastUsedPlaceholder = "last_used_placeholder" + } + + /// These constants match the server-side authentication code + public enum TwoFactorTypeLengths: Int { + case authenticator = 6 + case sms = 7 + case backup = 8 + } + + public func authTypeAndNonce(for code: String) -> (String, String) { + let typeNoncePair: (String, String) + switch code.count { + case TwoFactorTypeLengths.sms.rawValue: + typeNoncePair = ("sms", nonceSMS) + nonceSMS = Constants.lastUsedPlaceholder + case TwoFactorTypeLengths.backup.rawValue: + typeNoncePair = ("backup", nonceBackup) + nonceBackup = Constants.lastUsedPlaceholder + case TwoFactorTypeLengths.authenticator.rawValue: + fallthrough + default: + typeNoncePair = ("authenticator", nonceAuthenticator) + nonceAuthenticator = Constants.lastUsedPlaceholder + } + return typeNoncePair + } + + @objc public func updateNonce(with newNonce: String) { + switch Constants.lastUsedPlaceholder { + case nonceSMS: + nonceSMS = newNonce + case nonceBackup: + nonceBackup = newNonce + case nonceAuthenticator: + fallthrough + default: + nonceAuthenticator = newNonce + } + } +} diff --git a/Modules/Sources/WordPressKit/StatsAllAnnualInsight.swift b/Modules/Sources/WordPressKit/StatsAllAnnualInsight.swift new file mode 100644 index 000000000000..fe8c6a37ff1c --- /dev/null +++ b/Modules/Sources/WordPressKit/StatsAllAnnualInsight.swift @@ -0,0 +1,85 @@ +import Foundation +public struct StatsAllAnnualInsight: Codable { + public let allAnnualInsights: [StatsAnnualInsight] + + public init(allAnnualInsights: [StatsAnnualInsight]) { + self.allAnnualInsights = allAnnualInsights + } + + private enum CodingKeys: String, CodingKey { + case allAnnualInsights = "years" + } +} + +public struct StatsAnnualInsight: Codable { + public let year: Int + public let totalPostsCount: Int + public let totalWordsCount: Int + public let averageWordsCount: Double + public let totalLikesCount: Int + public let averageLikesCount: Double + public let totalCommentsCount: Int + public let averageCommentsCount: Double + public let totalImagesCount: Int + public let averageImagesCount: Double + + public init(year: Int, + totalPostsCount: Int, + totalWordsCount: Int, + averageWordsCount: Double, + totalLikesCount: Int, + averageLikesCount: Double, + totalCommentsCount: Int, + averageCommentsCount: Double, + totalImagesCount: Int, + averageImagesCount: Double) { + self.year = year + self.totalPostsCount = totalPostsCount + self.totalWordsCount = totalWordsCount + self.averageWordsCount = averageWordsCount + self.totalLikesCount = totalLikesCount + self.averageLikesCount = averageLikesCount + self.totalCommentsCount = totalCommentsCount + self.averageCommentsCount = averageCommentsCount + self.totalImagesCount = totalImagesCount + self.averageImagesCount = averageImagesCount + } + + private enum CodingKeys: String, CodingKey { + case year + case totalPostsCount = "total_posts" + case totalWordsCount = "total_words" + case averageWordsCount = "avg_words" + case totalLikesCount = "total_likes" + case averageLikesCount = "avg_likes" + case totalCommentsCount = "total_comments" + case averageCommentsCount = "avg_comments" + case totalImagesCount = "total_images" + case averageImagesCount = "avg_images" + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + if let year = Int(try container.decode(String.self, forKey: .year)) { + self.year = year + } else { + throw DecodingError.dataCorruptedError(forKey: .year, in: container, debugDescription: "Year cannot be parsed into number.") + } + totalPostsCount = (try? container.decodeIfPresent(Int.self, forKey: .totalPostsCount)) ?? 0 + totalWordsCount = (try? container.decodeIfPresent(Int.self, forKey: .totalWordsCount)) ?? 0 + averageWordsCount = (try? container.decodeIfPresent(Double.self, forKey: .averageWordsCount)) ?? 0 + totalLikesCount = (try? container.decodeIfPresent(Int.self, forKey: .totalLikesCount)) ?? 0 + averageLikesCount = (try? container.decodeIfPresent(Double.self, forKey: .averageLikesCount)) ?? 0 + totalCommentsCount = (try? container.decodeIfPresent(Int.self, forKey: .totalCommentsCount)) ?? 0 + averageCommentsCount = (try? container.decodeIfPresent(Double.self, forKey: .averageCommentsCount)) ?? 0 + totalImagesCount = (try? container.decodeIfPresent(Int.self, forKey: .totalImagesCount)) ?? 0 + averageImagesCount = (try? container.decodeIfPresent(Double.self, forKey: .averageImagesCount)) ?? 0 + } +} + +extension StatsAllAnnualInsight: StatsInsightData { + public static var pathComponent: String { + return "stats/insights" + } +} diff --git a/Modules/Sources/WordPressKit/StatsAllTimesInsight.swift b/Modules/Sources/WordPressKit/StatsAllTimesInsight.swift new file mode 100644 index 000000000000..de703cd1fee3 --- /dev/null +++ b/Modules/Sources/WordPressKit/StatsAllTimesInsight.swift @@ -0,0 +1,54 @@ +import Foundation +public struct StatsAllTimesInsight: Codable { + public let postsCount: Int + public let viewsCount: Int + public let bestViewsDay: Date + public let visitorsCount: Int + public let bestViewsPerDayCount: Int + + public init(postsCount: Int, + viewsCount: Int, + bestViewsDay: Date, + visitorsCount: Int, + bestViewsPerDayCount: Int) { + self.postsCount = postsCount + self.viewsCount = viewsCount + self.bestViewsDay = bestViewsDay + self.visitorsCount = visitorsCount + self.bestViewsPerDayCount = bestViewsPerDayCount + } + + private enum CodingKeys: String, CodingKey { + case postsCount = "posts" + case viewsCount = "views" + case bestViewsDay = "views_best_day" + case visitorsCount = "visitors" + case bestViewsPerDayCount = "views_best_day_total" + } + + private enum RootKeys: String, CodingKey { + case stats + } +} + +extension StatsAllTimesInsight: StatsInsightData { + public init (from decoder: Decoder) throws { + let rootContainer = try decoder.container(keyedBy: RootKeys.self) + let container = try rootContainer.nestedContainer(keyedBy: CodingKeys.self, forKey: .stats) + + self.postsCount = (try? container.decodeIfPresent(Int.self, forKey: .postsCount)) ?? 0 + self.bestViewsPerDayCount = (try? container.decodeIfPresent(Int.self, forKey: .bestViewsPerDayCount)) ?? 0 + self.visitorsCount = (try? container.decodeIfPresent(Int.self, forKey: .visitorsCount)) ?? 0 + + self.viewsCount = (try? container.decodeIfPresent(Int.self, forKey: .viewsCount)) ?? 0 + let bestViewsDayString = try container.decodeIfPresent(String.self, forKey: .bestViewsDay) ?? "" + self.bestViewsDay = StatsAllTimesInsight.dateFormatter.date(from: bestViewsDayString) ?? Date() + } + + // MARK: - + private static var dateFormatter: DateFormatter { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd" + return formatter + } +} diff --git a/Modules/Sources/WordPressKit/StatsAnnualAndMostPopularTimeInsight.swift b/Modules/Sources/WordPressKit/StatsAnnualAndMostPopularTimeInsight.swift new file mode 100644 index 000000000000..4d74523ed6c8 --- /dev/null +++ b/Modules/Sources/WordPressKit/StatsAnnualAndMostPopularTimeInsight.swift @@ -0,0 +1,92 @@ +import Foundation +public struct StatsAnnualAndMostPopularTimeInsight: Codable { + /// - A `DateComponents` object with one field populated: `weekday`. + public let mostPopularDayOfWeek: DateComponents + public let mostPopularDayOfWeekPercentage: Int + + /// - A `DateComponents` object with one field populated: `hour`. + public let mostPopularHour: DateComponents + public let mostPopularHourPercentage: Int + public let years: [Year]? + + private enum CodingKeys: String, CodingKey { + case mostPopularHour = "highest_hour" + case mostPopularHourPercentage = "highest_hour_percent" + case mostPopularDayOfWeek = "highest_day_of_week" + case mostPopularDayOfWeekPercentage = "highest_day_percent" + case years + } + + public struct Year: Codable { + public let year: String + public let totalPosts: Int + public let totalWords: Int + public let averageWords: Double + public let totalLikes: Int + public let averageLikes: Double + public let totalComments: Int + public let averageComments: Double + public let totalImages: Int + public let averageImages: Double + + private enum CodingKeys: String, CodingKey { + case year + case totalPosts = "total_posts" + case totalWords = "total_words" + case averageWords = "avg_words" + case totalLikes = "total_likes" + case averageLikes = "avg_likes" + case totalComments = "total_comments" + case averageComments = "avg_comments" + case totalImages = "total_images" + case averageImages = "avg_images" + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + year = try container.decode(String.self, forKey: .year) + totalPosts = (try? container.decodeIfPresent(Int.self, forKey: .totalPosts)) ?? 0 + totalWords = (try? container.decode(Int.self, forKey: .totalWords)) ?? 0 + averageWords = (try? container.decode(Double.self, forKey: .averageWords)) ?? 0 + totalLikes = (try? container.decode(Int.self, forKey: .totalLikes)) ?? 0 + averageLikes = (try? container.decode(Double.self, forKey: .averageLikes)) ?? 0 + totalComments = (try? container.decode(Int.self, forKey: .totalComments)) ?? 0 + averageComments = (try? container.decode(Double.self, forKey: .averageComments)) ?? 0 + totalImages = (try? container.decode(Int.self, forKey: .totalImages)) ?? 0 + averageImages = (try? container.decode(Double.self, forKey: .averageImages)) ?? 0 + } + } +} + +extension StatsAnnualAndMostPopularTimeInsight: StatsInsightData { + public static var pathComponent: String { + return "stats/insights" + } +} + +extension StatsAnnualAndMostPopularTimeInsight { + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let years = try container.decodeIfPresent([Year].self, forKey: .years) + let highestHour = try container.decode(Int.self, forKey: .mostPopularHour) + let highestHourPercentageValue = try container.decode(Double.self, forKey: .mostPopularHourPercentage) + let highestDayOfWeek = try container.decode(Int.self, forKey: .mostPopularDayOfWeek) + let highestDayOfWeekPercentageValue = try container.decode(Double.self, forKey: .mostPopularDayOfWeekPercentage) + + let mappedWeekday: ((Int) -> Int) = { + // iOS Calendar system is `1-based` and uses Sunday as the first day of the week. + // The data returned from WP.com is `0-based` and uses Monday as the first day of the week. + // This maps the WP.com data to iOS format. + return $0 == 6 ? 0 : $0 + 2 + } + + let weekDayComponent = DateComponents(weekday: mappedWeekday(highestDayOfWeek)) + let hourComponents = DateComponents(hour: highestHour) + + self.mostPopularDayOfWeek = weekDayComponent + self.mostPopularDayOfWeekPercentage = Int(highestDayOfWeekPercentageValue.rounded()) + self.mostPopularHour = hourComponents + self.mostPopularHourPercentage = Int(highestHourPercentageValue.rounded()) + self.years = years + } +} diff --git a/Modules/Sources/WordPressKit/StatsArchiveTimeIntervalData.swift b/Modules/Sources/WordPressKit/StatsArchiveTimeIntervalData.swift new file mode 100644 index 000000000000..b990c7fcf3bd --- /dev/null +++ b/Modules/Sources/WordPressKit/StatsArchiveTimeIntervalData.swift @@ -0,0 +1,82 @@ +import Foundation + +public struct StatsArchiveTimeIntervalData { + public let period: StatsPeriodUnit + public let unit: StatsPeriodUnit? + public let periodEndDate: Date + public let summary: [String: [StatsArchiveItem]] + + public init(period: StatsPeriodUnit, + unit: StatsPeriodUnit? = nil, + periodEndDate: Date, + summary: [String: [StatsArchiveItem]]) { + self.period = period + self.unit = unit + self.periodEndDate = periodEndDate + self.summary = summary + } +} + +public struct StatsArchiveItem { + public let href: String + public let value: String + public let views: Int + + public init(href: String, value: String, views: Int) { + self.href = href + self.value = value + self.views = views + } +} + +extension StatsArchiveTimeIntervalData: StatsTimeIntervalData { + public static var pathComponent: String { + return "stats/archives" + } + + public static func queryProperties(with date: Date, period: StatsPeriodUnit, maxCount: Int) -> [String: String] { + return ["max": String(maxCount)] + } + + public init?(date: Date, period: StatsPeriodUnit, jsonDictionary: [String: AnyObject]) { + self.init(date: date, period: period, unit: nil, jsonDictionary: jsonDictionary) + } + + public init?(date: Date, period: StatsPeriodUnit, unit: StatsPeriodUnit?, jsonDictionary: [String: AnyObject]) { + guard let summary = jsonDictionary["summary"] as? [String: AnyObject] else { + return nil + } + + self.period = period + self.unit = unit + self.periodEndDate = date + self.summary = { + var map: [String: [StatsArchiveItem]] = [:] + for (key, value) in summary { + let items = (value as? [[String: AnyObject]])?.compactMap { + StatsArchiveItem(jsonDictionary: $0) + } ?? [] + if !items.isEmpty { + map[key] = items + } + } + return map + }() + } +} + +private extension StatsArchiveItem { + init?(jsonDictionary: [String: AnyObject]) { + guard + let href = jsonDictionary["href"] as? String, + let value = jsonDictionary["value"] as? String, + let views = jsonDictionary["views"] as? Int + else { + return nil + } + + self.href = href + self.value = value + self.views = views + } +} diff --git a/Modules/Sources/WordPressKit/StatsCommentsInsight.swift b/Modules/Sources/WordPressKit/StatsCommentsInsight.swift new file mode 100644 index 000000000000..3cc311006654 --- /dev/null +++ b/Modules/Sources/WordPressKit/StatsCommentsInsight.swift @@ -0,0 +1,118 @@ +import Foundation +public struct StatsCommentsInsight: Codable { + public let topPosts: [StatsTopCommentsPost] + public let topAuthors: [StatsTopCommentsAuthor] + + public init(topPosts: [StatsTopCommentsPost], + topAuthors: [StatsTopCommentsAuthor]) { + self.topPosts = topPosts + self.topAuthors = topAuthors + } + + private enum CodingKeys: String, CodingKey { + case topPosts = "posts" + case topAuthors = "authors" + } +} + +extension StatsCommentsInsight: StatsInsightData { + + // MARK: - StatsInsightData Conformance + public static var pathComponent: String { + return "stats/comments" + } +} + +public struct StatsTopCommentsAuthor: Codable { + public let name: String + public let commentCount: Int + public let iconURL: URL? + + public init(name: String, + commentCount: Int, + iconURL: URL?) { + self.name = name + self.commentCount = commentCount + self.iconURL = iconURL + } + + private enum CodingKeys: String, CodingKey { + case name + case commentCount = "comments" + case iconURL = "gravatar" + } +} + +public struct StatsTopCommentsPost: Codable { + public let name: String + public let postID: String + public let commentCount: Int + public let postURL: URL? + + public init(name: String, + postID: String, + commentCount: Int, + postURL: URL?) { + self.name = name + self.postID = postID + self.commentCount = commentCount + self.postURL = postURL + } + + private enum CodingKeys: String, CodingKey { + case name + case postID = "id" + case commentCount = "comments" + case postURL = "link" + } +} + +private extension StatsTopCommentsAuthor { + init(name: String, avatar: String?, commentCount: Int) { + let url: URL? + + if let avatar, var components = URLComponents(string: avatar) { + components.query = "d=mm&s=60" // to get a properly-sized avatar. + url = components.url + } else { + url = nil + } + + self.name = name + self.commentCount = commentCount + self.iconURL = url + } +} + +public extension StatsTopCommentsAuthor { + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let name = try container.decode(String.self, forKey: .name) + let commentCount: Int + if let comments = try? container.decodeIfPresent(String.self, forKey: .commentCount) { + commentCount = Int(comments) ?? 0 + } else { + commentCount = 0 + } + let iconURL = try container.decodeIfPresent(String.self, forKey: .iconURL) + + self.init(name: name, avatar: iconURL, commentCount: commentCount) + } +} + +public extension StatsTopCommentsPost { + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let name = try container.decode(String.self, forKey: .name) + let postID = try container.decode(String.self, forKey: .postID) + let commentCount: Int + if let comments = try? container.decodeIfPresent(String.self, forKey: .commentCount) { + commentCount = Int(comments) ?? 0 + } else { + commentCount = 0 + } + let postURL = try container.decodeIfPresent(URL.self, forKey: .postURL) + + self.init(name: name, postID: postID, commentCount: commentCount, postURL: postURL) + } +} diff --git a/Modules/Sources/WordPressKit/StatsDotComFollowersInsight.swift b/Modules/Sources/WordPressKit/StatsDotComFollowersInsight.swift new file mode 100644 index 000000000000..1dded0d4c73b --- /dev/null +++ b/Modules/Sources/WordPressKit/StatsDotComFollowersInsight.swift @@ -0,0 +1,94 @@ +import Foundation +public struct StatsDotComFollowersInsight: Codable { + public let dotComFollowersCount: Int + public let topDotComFollowers: [StatsFollower] + + public init (dotComFollowersCount: Int, + topDotComFollowers: [StatsFollower]) { + self.dotComFollowersCount = dotComFollowersCount + self.topDotComFollowers = topDotComFollowers + } + + private enum CodingKeys: String, CodingKey { + case dotComFollowersCount = "total_wpcom" + case topDotComFollowers = "subscribers" + } +} + +extension StatsDotComFollowersInsight: StatsInsightData { + + // MARK: - StatsInsightData Conformance + public static func queryProperties(with maxCount: Int) -> [String: String] { + return ["type": "wpcom", + "max": String(maxCount)] + } + + public static var pathComponent: String { + return "stats/followers" + } + + fileprivate static let dateFormatter = ISO8601DateFormatter() +} + +public struct StatsFollower: Codable, Equatable { + public let id: String? + public let name: String + public let subscribedDate: Date + public let avatarURL: URL? + + public init(name: String, + subscribedDate: Date, + avatarURL: URL?, + id: String? = nil) { + self.name = name + self.subscribedDate = subscribedDate + self.avatarURL = avatarURL + self.id = id + } + + private enum CodingKeys: String, CodingKey { + case id = "ID" + case name = "label" + case subscribedDate = "date_subscribed" + case avatarURL = "avatar" + } +} + +extension StatsFollower { + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.name = try container.decode(String.self, forKey: .name) + if let id = try? container.decodeIfPresent(Int.self, forKey: .id) { + self.id = "\(id)" + } else if let id = try? container.decodeIfPresent(String.self, forKey: .id) { + self.id = id + } else { + self.id = nil + } + + let avatar = try? container.decodeIfPresent(String.self, forKey: .avatarURL) + if let avatar, var components = URLComponents(string: avatar) { + components.query = "d=mm&s=60" // to get a properly-sized avatar. + self.avatarURL = components.url + } else { + self.avatarURL = nil + } + + let dateString = try container.decode(String.self, forKey: .subscribedDate) + if let date = StatsDotComFollowersInsight.dateFormatter.date(from: dateString) { + self.subscribedDate = date + } else { + throw DecodingError.dataCorruptedError(forKey: .subscribedDate, in: container, debugDescription: "Date string does not match format expected by formatter.") + } + } + + init?(jsonDictionary: [String: AnyObject]) { + do { + let jsonData = try JSONSerialization.data(withJSONObject: jsonDictionary, options: []) + let decoder = JSONDecoder() + self = try decoder.decode(StatsFollower.self, from: jsonData) + } catch { + return nil + } + } +} diff --git a/Modules/Sources/WordPressKit/StatsEmailFollowersInsight.swift b/Modules/Sources/WordPressKit/StatsEmailFollowersInsight.swift new file mode 100644 index 000000000000..dc2fc994a272 --- /dev/null +++ b/Modules/Sources/WordPressKit/StatsEmailFollowersInsight.swift @@ -0,0 +1,29 @@ +import Foundation +public struct StatsEmailFollowersInsight: Codable { + public let emailFollowersCount: Int + public let topEmailFollowers: [StatsFollower] + + public init(emailFollowersCount: Int, + topEmailFollowers: [StatsFollower]) { + self.emailFollowersCount = emailFollowersCount + self.topEmailFollowers = topEmailFollowers + } + + private enum CodingKeys: String, CodingKey { + case emailFollowersCount = "total_email" + case topEmailFollowers = "subscribers" + } +} + +extension StatsEmailFollowersInsight: StatsInsightData { + + // MARK: - StatsInsightData Conformance + public static func queryProperties(with maxCount: Int) -> [String: String] { + return ["type": "email", + "max": String(maxCount)] + } + + public static var pathComponent: String { + return "stats/followers" + } +} diff --git a/Modules/Sources/WordPressKit/StatsEmailOpensData.swift b/Modules/Sources/WordPressKit/StatsEmailOpensData.swift new file mode 100644 index 000000000000..9fe521677478 --- /dev/null +++ b/Modules/Sources/WordPressKit/StatsEmailOpensData.swift @@ -0,0 +1,34 @@ +import Foundation + +public struct StatsEmailOpensData: Decodable, Equatable { + public let totalSends: Int? + public let uniqueOpens: Int? + public let totalOpens: Int? + public let opensRate: Double? + + public init(totalSends: Int?, uniqueOpens: Int?, totalOpens: Int?, opensRate: Double?) { + self.totalSends = totalSends + self.uniqueOpens = uniqueOpens + self.totalOpens = totalOpens + self.opensRate = opensRate + } + + private enum CodingKeys: String, CodingKey { + case totalSends = "total_sends" + case uniqueOpens = "unique_opens" + case totalOpens = "total_opens" + case opensRate = "opens_rate" + } +} + +extension StatsEmailOpensData { + public init?(jsonDictionary: [String: AnyObject]) { + do { + let jsonData = try JSONSerialization.data(withJSONObject: jsonDictionary, options: []) + let decoder = JSONDecoder() + self = try decoder.decode(Self.self, from: jsonData) + } catch { + return nil + } + } +} diff --git a/Modules/Sources/WordPressKit/StatsEmailsSummaryData.swift b/Modules/Sources/WordPressKit/StatsEmailsSummaryData.swift new file mode 100644 index 000000000000..4ed6d59f4678 --- /dev/null +++ b/Modules/Sources/WordPressKit/StatsEmailsSummaryData.swift @@ -0,0 +1,94 @@ +import Foundation + +public struct StatsEmailsSummaryData: Decodable, Equatable { + public let posts: [Post] + + public init(posts: [Post]) { + self.posts = posts + } + + private enum CodingKeys: String, CodingKey { + case posts = "posts" + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + posts = try container.decode([Post].self, forKey: .posts) + } + + public struct Post: Codable, Equatable { + public let id: Int + public let link: URL + public let date: Date + public let title: String + public let type: PostType + public let opens: Int + public let clicks: Int + + public init(id: Int, link: URL, date: Date, title: String, type: PostType, opens: Int, clicks: Int) { + self.id = id + self.link = link + self.date = date + self.title = title + self.type = type + self.opens = opens + self.clicks = clicks + } + + public enum PostType: String, Codable { + case post = "post" + } + + private enum CodingKeys: String, CodingKey { + case id = "id" + case link = "href" + case date = "date" + case title = "title" + case type = "type" + case opens = "opens" + case clicks = "clicks" + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decode(Int.self, forKey: .id) + link = try container.decode(URL.self, forKey: .link) + title = (try? container.decodeIfPresent(String.self, forKey: .title)) ?? "" + type = (try? container.decodeIfPresent(PostType.self, forKey: .type)) ?? .post + opens = (try? container.decodeIfPresent(Int.self, forKey: .opens)) ?? 0 + clicks = (try? container.decodeIfPresent(Int.self, forKey: .clicks)) ?? 0 + self.date = try container.decode(Date.self, forKey: .date) + } + } +} + +extension StatsEmailsSummaryData { + public static var pathComponent: String { + return "stats/emails/summary" + } + + public init?(jsonDictionary: [String: AnyObject]) { + do { + let jsonData = try JSONSerialization.data(withJSONObject: jsonDictionary, options: []) + let decoder = JSONDecoder.apiDecoder + self = try decoder.decode(Self.self, from: jsonData) + } catch { + return nil + } + } + + public static func queryProperties(quantity: Int, sortField: SortField, sortOrder: SortOrder) -> [String: String] { + return ["quantity": String(quantity), "sort_field": sortField.rawValue, "sort_order": sortOrder.rawValue] + } + + public enum SortField: String { + case opens = "opens" + case postId = "post_id" + case postDate = "post_date" + } + + public enum SortOrder: String { + case descending = "desc" + case ascending = "ASC" + } +} diff --git a/Modules/Sources/WordPressKit/StatsFileDownloadsTimeIntervalData.swift b/Modules/Sources/WordPressKit/StatsFileDownloadsTimeIntervalData.swift new file mode 100644 index 000000000000..28eb5ba1d926 --- /dev/null +++ b/Modules/Sources/WordPressKit/StatsFileDownloadsTimeIntervalData.swift @@ -0,0 +1,66 @@ +import Foundation +public struct StatsFileDownloadsTimeIntervalData { + public let period: StatsPeriodUnit + public let periodEndDate: Date + + public let totalDownloadsCount: Int + public let otherDownloadsCount: Int + public let fileDownloads: [StatsFileDownload] + + public init(period: StatsPeriodUnit, + periodEndDate: Date, + fileDownloads: [StatsFileDownload], + totalDownloadsCount: Int, + otherDownloadsCount: Int) { + self.period = period + self.periodEndDate = periodEndDate + self.fileDownloads = fileDownloads + self.totalDownloadsCount = totalDownloadsCount + self.otherDownloadsCount = otherDownloadsCount + } +} + +public struct StatsFileDownload { + public let file: String + public let downloadCount: Int + + public init(file: String, + downloadCount: Int) { + self.file = file + self.downloadCount = downloadCount + } +} + +extension StatsFileDownloadsTimeIntervalData: StatsTimeIntervalData { + public static var pathComponent: String { + return "stats/file-downloads" + } + + public static func queryProperties(with date: Date, period: StatsPeriodUnit, maxCount: Int) -> [String: String] { + // num = number of periods to include in the query. default: 1. + return ["num": String(maxCount)] + } + + public init?(date: Date, period: StatsPeriodUnit, jsonDictionary: [String: AnyObject]) { + guard + let unwrappedDays = type(of: self).unwrapDaysDictionary(jsonDictionary: jsonDictionary), + let fileDownloadsDict = unwrappedDays["files"] as? [[String: AnyObject]] + else { + return nil + } + + let fileDownloads: [StatsFileDownload] = fileDownloadsDict.compactMap { + guard let file = $0["filename"] as? String, let downloads = $0["downloads"] as? Int else { + return nil + } + + return StatsFileDownload(file: file, downloadCount: downloads) + } + + self.periodEndDate = date + self.period = period + self.fileDownloads = fileDownloads + self.totalDownloadsCount = unwrappedDays["total_downloads"] as? Int ?? 0 + self.otherDownloadsCount = unwrappedDays["other_downloads"] as? Int ?? 0 + } +} diff --git a/Modules/Sources/WordPressKit/StatsLastPostInsight.swift b/Modules/Sources/WordPressKit/StatsLastPostInsight.swift new file mode 100644 index 000000000000..9319c951df01 --- /dev/null +++ b/Modules/Sources/WordPressKit/StatsLastPostInsight.swift @@ -0,0 +1,98 @@ +import Foundation + +public struct StatsLastPostInsight: Equatable, Decodable { + public let title: String + public let url: URL + public let publishedDate: Date + public let likesCount: Int + public let commentsCount: Int + public private(set) var viewsCount: Int = 0 + public let postID: Int + public let featuredImageURL: URL? + + public init(title: String, + url: URL, + publishedDate: Date, + likesCount: Int, + commentsCount: Int, + viewsCount: Int, + postID: Int, + featuredImageURL: URL?) { + self.title = title + self.url = url + self.publishedDate = publishedDate + self.likesCount = likesCount + self.commentsCount = commentsCount + self.viewsCount = viewsCount + self.postID = postID + self.featuredImageURL = featuredImageURL + } +} + +extension StatsLastPostInsight: StatsInsightData { + + // MARK: - StatsInsightData Conformance + public static func queryProperties(with maxCount: Int) -> [String: String] { + return ["order_by": "date", + "number": "1", + "type": "post", + "fields": "ID, title, URL, discussion, like_count, date, featured_image"] + } + + public static var pathComponent: String { + return "posts/" + } + + public init?(jsonDictionary: [String: AnyObject]) { + self.init(jsonDictionary: jsonDictionary, views: 0) + } + + // MARK: - + + private static let dateFormatter = ISO8601DateFormatter() + + public init?(jsonDictionary: [String: AnyObject], views: Int) { + do { + let jsonData = try JSONSerialization.data(withJSONObject: jsonDictionary, options: []) + let decoder = JSONDecoder() + self = try decoder.decode(StatsLastPostInsight.self, from: jsonData) + self.viewsCount = views + } catch { + return nil + } + } +} + +extension StatsLastPostInsight { + private enum CodingKeys: String, CodingKey { + case title + case url = "URL" + case publishedDate = "date" + case likesCount = "like_count" + case commentsCount + case postID = "ID" + case featuredImageURL = "featured_image" + case discussion + } + + private enum DiscussionKeys: String, CodingKey { + case commentsCount = "comment_count" + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + title = try container.decode(String.self, forKey: .title).trimmingCharacters(in: .whitespaces).wpkit_stringByDecodingXMLCharacters() + url = try container.decode(URL.self, forKey: .url) + let dateString = try container.decode(String.self, forKey: .publishedDate) + guard let date = StatsLastPostInsight.dateFormatter.date(from: dateString) else { + throw DecodingError.dataCorruptedError(forKey: .publishedDate, in: container, debugDescription: "Date string does not match format expected by formatter.") + } + publishedDate = date + likesCount = (try? container.decodeIfPresent(Int.self, forKey: .likesCount)) ?? 0 + postID = try container.decode(Int.self, forKey: .postID) + featuredImageURL = try? container.decodeIfPresent(URL.self, forKey: .featuredImageURL) + + let discussionContainer = try container.nestedContainer(keyedBy: DiscussionKeys.self, forKey: .discussion) + commentsCount = (try? discussionContainer.decodeIfPresent(Int.self, forKey: .commentsCount)) ?? 0 + } +} diff --git a/Modules/Sources/WordPressKit/StatsPostDetails.swift b/Modules/Sources/WordPressKit/StatsPostDetails.swift new file mode 100644 index 000000000000..214ded4ff212 --- /dev/null +++ b/Modules/Sources/WordPressKit/StatsPostDetails.swift @@ -0,0 +1,274 @@ +import Foundation + +public struct StatsPostDetails: Equatable { + public let fetchedDate: Date + public let totalViewsCount: Int + + public let recentWeeks: [StatsWeeklyBreakdown] + public let dailyAveragesPerMonth: [StatsPostViews] + public let monthlyBreakdown: [StatsPostViews] + public let lastTwoWeeks: [StatsPostViews] + public let data: [StatsPostViews] + + public let highestMonth: Int? + public let highestDayAverage: Int? + public let highestWeekAverage: Int? + + public let yearlyTotals: [Int: Int] + public let overallAverages: [Int: Int] + + public let fields: [String]? + + public let post: Post? + + public struct Post: Equatable { + public let postID: Int + public let title: String + public let authorID: String? + public let dateGMT: Date? + public let content: String? + public let excerpt: String? + public let status: String? + public let commentStatus: String? + public let password: String? + public let name: String? + public let modifiedGMT: Date? + public let contentFiltered: String? + public let parent: Int? + public let guid: String? + public let type: String? + public let mimeType: String? + public let commentCount: String? + public let permalink: String? + + init?(jsonDictionary: [String: AnyObject]) { + guard + let postID = jsonDictionary["ID"] as? Int, + let title = jsonDictionary["post_title"] as? String + else { + return nil + } + + let dateFormatter = DateFormatter() + dateFormatter.locale = Locale(identifier: "en_US_POSIX") + dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss" + + var dateGMT: Date? + var modifiedGMT: Date? + + if let postDateGMTString = jsonDictionary["post_date_gmt"] as? String { + dateGMT = dateFormatter.date(from: postDateGMTString) + } + if let postModifiedGMTString = jsonDictionary["post_modified_gmt"] as? String { + modifiedGMT = dateFormatter.date(from: postModifiedGMTString) + } + + self.postID = postID + self.title = title + self.authorID = jsonDictionary["post_author"] as? String + self.dateGMT = dateGMT + self.content = jsonDictionary["post_content"] as? String + self.excerpt = jsonDictionary["post_excerpt"] as? String + self.status = jsonDictionary["post_status"] as? String + self.commentStatus = jsonDictionary["comment_status"] as? String + self.password = jsonDictionary["post_password"] as? String + self.name = jsonDictionary["post_name"] as? String + self.modifiedGMT = modifiedGMT + self.contentFiltered = jsonDictionary["post_content_filtered"] as? String + self.parent = jsonDictionary["post_parent"] as? Int + self.guid = jsonDictionary["guid"] as? String + self.type = jsonDictionary["post_type"] as? String + self.mimeType = jsonDictionary["post_mime_type"] as? String + self.commentCount = jsonDictionary["comment_count"] as? String + self.permalink = jsonDictionary["permalink"] as? String + } + } +} + +public struct StatsWeeklyBreakdown: Equatable { + public let startDay: DateComponents + public let endDay: DateComponents + + public let totalViewsCount: Int + public let averageViewsCount: Int + public let changePercentage: Double + public let isChangeInfinity: Bool + + public let days: [StatsPostViews] +} + +public struct StatsPostViews: Equatable { + public let period: StatsPeriodUnit + public let date: DateComponents + public let viewsCount: Int +} + +extension StatsPostDetails { + public init?(jsonDictionary: [String: AnyObject]) { + guard + let fetchedDateString = jsonDictionary["date"] as? String, + let date = type(of: self).dateFormatter.date(from: fetchedDateString), + let totalViewsCount = jsonDictionary["views"] as? Int, + let monthlyBreakdown = jsonDictionary["years"] as? [String: AnyObject], + let monthlyAverages = jsonDictionary["averages"] as? [String: AnyObject], + let recentWeeks = jsonDictionary["weeks"] as? [[String: AnyObject]], + let data = jsonDictionary["data"] as? [[Any]] + else { + return nil + } + + self.fetchedDate = date + self.totalViewsCount = totalViewsCount + + self.data = StatsPostViews.mapDailyData(data: data) + + // It's very hard to describe the format of this response. I tried to make the parsing + // as nice and readable as possible, but in all honestly it's still pretty nasty. + // If you want to see an example response to see how weird this response is, check out + // `stats-post-details.json`. + self.recentWeeks = StatsPostViews.mapWeeklyBreakdown(jsonDictionary: recentWeeks) + self.monthlyBreakdown = StatsPostViews.mapMonthlyBreakdown(jsonDictionary: monthlyBreakdown) + self.dailyAveragesPerMonth = StatsPostViews.mapMonthlyBreakdown(jsonDictionary: monthlyAverages) + self.lastTwoWeeks = StatsPostViews.mapDailyData(data: Array(data.suffix(14))) + + // Parse new fields + self.highestMonth = jsonDictionary["highest_month"] as? Int + self.highestDayAverage = jsonDictionary["highest_day_average"] as? Int + self.highestWeekAverage = jsonDictionary["highest_week_average"] as? Int + + self.fields = jsonDictionary["fields"] as? [String] + + // Parse yearly totals + var yearlyTotals: [Int: Int] = [:] + if let years = monthlyBreakdown as? [String: [String: AnyObject]] { + for (yearKey, yearData) in years { + if let yearInt = Int(yearKey), let total = yearData["total"] as? Int { + yearlyTotals[yearInt] = total + } + } + } + self.yearlyTotals = yearlyTotals + + // Parse overall averages + var overallAverages: [Int: Int] = [:] + if let averages = monthlyAverages as? [String: [String: AnyObject]] { + for (yearKey, yearData) in averages { + if let yearInt = Int(yearKey), let overall = yearData["overall"] as? Int { + overallAverages[yearInt] = overall + } + } + } + self.overallAverages = overallAverages + + // Parse post object using the new Post model + if let postDict = jsonDictionary["post"] as? [String: AnyObject] { + self.post = Post(jsonDictionary: postDict) + } else { + self.post = nil + } + } + + static var dateFormatter: DateFormatter { + let df = DateFormatter() + df.locale = Locale(identifier: "en_US_POS") + df.dateFormat = "yyyy-MM-dd" + return df + } +} + +extension StatsPostViews { + static func mapMonthlyBreakdown(jsonDictionary: [String: AnyObject]) -> [StatsPostViews] { + return jsonDictionary.flatMap { yearKey, value -> [StatsPostViews] in + guard + let yearInt = Int(yearKey), + let monthsDict = value as? [String: AnyObject], + let months = monthsDict["months"] as? [String: Int] + else { + return [] + } + + return months.compactMap { monthKey, value in + guard + let month = Int(monthKey) + else { + return nil + } + + return StatsPostViews(period: .month, + date: DateComponents(year: yearInt, month: month), + viewsCount: value) + } + } + } +} + +extension StatsPostViews { + static func mapWeeklyBreakdown(jsonDictionary: [[String: AnyObject]]) -> [StatsWeeklyBreakdown] { + return jsonDictionary.compactMap { + guard + let totalViews = $0["total"] as? Int, + let averageViews = $0["average"] as? Int, + let days = $0["days"] as? [[String: AnyObject]] + else { + return nil + } + + var change: Double = 0.0 + var isChangeInfinity = false + + if let changeValue = $0["change"] { + if let changeDict = changeValue as? [String: AnyObject], + let isInfinity = changeDict["isInfinity"] as? Bool { + isChangeInfinity = isInfinity + change = isInfinity ? Double.infinity : 0.0 + } else if let changeDouble = changeValue as? Double { + change = changeDouble + } + } + + let mappedDays: [StatsPostViews] = days.compactMap { + guard + let dayString = $0["day"] as? String, + let date = StatsPostDetails.dateFormatter.date(from: dayString), + let viewsCount = $0["count"] as? Int + else { + return nil + } + + return StatsPostViews(period: .day, + date: Calendar.autoupdatingCurrent.dateComponents([.year, .month, .day], from: date), + viewsCount: viewsCount) + } + + guard !mappedDays.isEmpty else { + return nil + } + + return StatsWeeklyBreakdown(startDay: mappedDays.first!.date, + endDay: mappedDays.last!.date, + totalViewsCount: totalViews, + averageViewsCount: averageViews, + changePercentage: change, + isChangeInfinity: isChangeInfinity, + days: mappedDays) + } + } +} + +extension StatsPostViews { + static func mapDailyData(data: [[Any]]) -> [StatsPostViews] { + return data.compactMap { + guard + let dateString = $0[0] as? String, + let date = StatsPostDetails.dateFormatter.date(from: dateString), + let viewsCount = $0[1] as? Int + else { + return nil + } + + return StatsPostViews(period: .day, + date: Calendar.autoupdatingCurrent.dateComponents([.year, .month, .day], from: date), + viewsCount: viewsCount) + } + } +} diff --git a/Modules/Sources/WordPressKit/StatsPostingStreakInsight.swift b/Modules/Sources/WordPressKit/StatsPostingStreakInsight.swift new file mode 100644 index 000000000000..9f2bcdd14a57 --- /dev/null +++ b/Modules/Sources/WordPressKit/StatsPostingStreakInsight.swift @@ -0,0 +1,149 @@ +import Foundation +public struct StatsPostingStreakInsight: Equatable, Codable { + public let streaks: PostingStreaks + public let postingEvents: [PostingStreakEvent] + + public var currentStreakStart: Date? { + streaks.current?.start + } + + public var currentStreakEnd: Date? { + streaks.current?.end + } + public var currentStreakLength: Int? { + streaks.current?.length + } + + public var longestStreakStart: Date? { + streaks.long?.start ?? currentStreakStart + } + public var longestStreakEnd: Date? { + streaks.long?.end ?? currentStreakEnd + } + + public var longestStreakLength: Int? { + streaks.long?.length ?? currentStreakLength + } + + private enum CodingKeys: String, CodingKey { + case streaks = "streak" + case postingEvents = "data" + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.streaks = try container.decode(PostingStreaks.self, forKey: .streaks) + let postsData = (try? container.decodeIfPresent([String: Int].self, forKey: .postingEvents)) ?? [:] + + let postingDates = postsData.keys + .compactMap { Double($0) } + .map { Date(timeIntervalSince1970: $0) } + .map { Calendar.autoupdatingCurrent.startOfDay(for: $0) } + + if postingDates.isEmpty { + self.postingEvents = [] + } else { + let countedPosts = NSCountedSet(array: postingDates) + self.postingEvents = countedPosts.compactMap { value in + if let date = value as? Date { + return PostingStreakEvent(date: date, postCount: countedPosts.count(for: value)) + } else { + return nil + } + } + } + } +} + +public struct PostingStreakEvent: Equatable, Codable { + public let date: Date + public let postCount: Int + + public init(date: Date, postCount: Int) { + self.date = date + self.postCount = postCount + } +} + +public struct PostingStreaks: Equatable, Codable { + public let long: PostingStreak? + public let current: PostingStreak? + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + self.long = try? container.decodeIfPresent(PostingStreak.self, forKey: .long) + self.current = try? container.decodeIfPresent(PostingStreak.self, forKey: .current) + } +} + +public struct PostingStreak: Equatable, Codable { + public let start: Date + public let end: Date + public let length: Int + + private enum CodingKeys: String, CodingKey { + case start + case end + case length + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let startValue = try container.decode(String.self, forKey: .start) + if let start = StatsPostingStreakInsight.dateFormatter.date(from: startValue) { + self.start = start + } else { + throw DecodingError.dataCorruptedError(forKey: .start, in: container, debugDescription: "Start date string doesn't match expected format") + } + + let endValue = try container.decode(String.self, forKey: .end) + if let end = StatsPostingStreakInsight.dateFormatter.date(from: endValue) { + self.end = end + } else { + throw DecodingError.dataCorruptedError(forKey: .end, in: container, debugDescription: "End date string doesn't match expected format") + } + + length = try container.decodeIfPresent(Int.self, forKey: .length) ?? 0 + } +} + +extension StatsPostingStreakInsight: StatsInsightData { + + // MARK: - StatsInsightData Conformance + public static var pathComponent: String { + return "stats/streak" + } + + // Some heavy-traffic sites can have A LOT of posts and the default query parameters wouldn't + // return all the relevant streak data, so we manualy override the `max` and `startDate``/endDate` + // parameters to hopefully get all. + public static var queryProperties: [String: String] { + let today = Date() + + let numberOfDaysInCurrentMonth = Calendar.autoupdatingCurrent.range(of: .day, in: .month, for: today) + + guard + let firstDayIndex = numberOfDaysInCurrentMonth?.first, + let lastDayIndex = numberOfDaysInCurrentMonth?.last, + let lastDayOfMonth = Calendar.autoupdatingCurrent.date(bySetting: .day, value: lastDayIndex, of: today), + let firstDayOfMonth = Calendar.autoupdatingCurrent.date(bySetting: .day, value: firstDayIndex, of: today), + let yearAgo = Calendar.autoupdatingCurrent.date(byAdding: .year, value: -1, to: firstDayOfMonth) + else { + return [:] + } + + let firstDayString = self.dateFormatter.string(from: yearAgo) + let lastDayString = self.dateFormatter.string(from: lastDayOfMonth) + + return ["startDate": "\(firstDayString)", + "endDate": "\(lastDayString)", + "max": "5000"] + } + + fileprivate static var dateFormatter: DateFormatter { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd" + return formatter + } +} diff --git a/Modules/Sources/WordPressKit/StatsPublicizeInsight.swift b/Modules/Sources/WordPressKit/StatsPublicizeInsight.swift new file mode 100644 index 000000000000..ab3dbb4ff7fc --- /dev/null +++ b/Modules/Sources/WordPressKit/StatsPublicizeInsight.swift @@ -0,0 +1,84 @@ +import Foundation +public struct StatsPublicizeInsight: Codable { + public let publicizeServices: [StatsPublicizeService] + + public init(publicizeServices: [StatsPublicizeService]) { + self.publicizeServices = publicizeServices + } + + private enum CodingKeys: String, CodingKey { + case publicizeServices = "services" + } +} + +extension StatsPublicizeInsight: StatsInsightData { + + // MARK: - StatsInsightData Conformance + public static var pathComponent: String { + return "stats/publicize" + } +} + +public struct StatsPublicizeService: Codable { + public let name: String + public let followers: Int + public let iconURL: URL? + + public init(name: String, + followers: Int, + iconURL: URL?) { + self.name = name + self.followers = followers + self.iconURL = iconURL + } + + private enum CodingKeys: String, CodingKey { + case name = "service" + case followers + } +} + +private extension StatsPublicizeService { + init(name: String, followers: Int) { + let niceName: String + let icon: URL? + + switch name { + case "facebook": + niceName = "Facebook" + icon = URL(string: "https://secure.gravatar.com/blavatar/2343ec78a04c6ea9d80806345d31fd78?s=60") + case "twitter": + niceName = "Twitter" + icon = URL(string: "https://secure.gravatar.com/blavatar/7905d1c4e12c54933a44d19fcd5f9356?s=60") + case "tumblr": + niceName = "Tumblr" + icon = URL(string: "https://secure.gravatar.com/blavatar/84314f01e87cb656ba5f382d22d85134?s=60") + case "google_plus": + niceName = "Google+" + icon = URL(string: "https://secure.gravatar.com/blavatar/4a4788c1dfc396b1f86355b274cc26b3?s=60") + case "linkedin": + niceName = "LinkedIn" + icon = URL(string: "https://secure.gravatar.com/blavatar/f54db463750940e0e7f7630fe327845e?s=60") + case "path": + niceName = "path" + icon = URL(string: "https://secure.gravatar.com/blavatar/3a03c8ce5bf1271fb3760bb6e79b02c1?s=60") + default: + niceName = name + icon = nil + } + + self.name = niceName + self.followers = followers + self.iconURL = icon + } +} + +public extension StatsPublicizeService { + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let name = try container.decode(String.self, forKey: .name) + let followers = (try? container.decodeIfPresent(Int.self, forKey: .followers)) ?? 0 + + self.init(name: name, followers: followers) + } +} diff --git a/Modules/Sources/WordPressKit/StatsPublishedPostsTimeIntervalData.swift b/Modules/Sources/WordPressKit/StatsPublishedPostsTimeIntervalData.swift new file mode 100644 index 000000000000..b9e91ac9a462 --- /dev/null +++ b/Modules/Sources/WordPressKit/StatsPublishedPostsTimeIntervalData.swift @@ -0,0 +1,49 @@ +import Foundation +public struct StatsPublishedPostsTimeIntervalData { + public let periodEndDate: Date + public let period: StatsPeriodUnit + + public let publishedPosts: [StatsTopPost] + + public init(period: StatsPeriodUnit, + periodEndDate: Date, + publishedPosts: [StatsTopPost]) { + self.period = period + self.periodEndDate = periodEndDate + self.publishedPosts = publishedPosts + } +} + +extension StatsPublishedPostsTimeIntervalData: StatsTimeIntervalData { + public static var pathComponent: String { + return "posts/" + } + + public init?(date: Date, period: StatsPeriodUnit, jsonDictionary: [String: AnyObject]) { + guard let posts = jsonDictionary["posts"] as? [[String: AnyObject]] else { + return nil + } + + self.periodEndDate = date + self.period = period + self.publishedPosts = posts.compactMap { StatsTopPost(postsJSONDictionary: $0) } + } +} + +private extension StatsTopPost { + init?(postsJSONDictionary: [String: AnyObject]) { + guard + let id = postsJSONDictionary["ID"] as? Int, + let title = postsJSONDictionary["title"] as? String, + let urlString = postsJSONDictionary["URL"] as? String + else { + return nil + } + + self.postID = id + self.title = title + self.postURL = URL(string: urlString) + self.viewsCount = 0 + self.kind = .unknown + } +} diff --git a/Modules/Sources/WordPressKit/StatsSearchTermTimeIntervalData.swift b/Modules/Sources/WordPressKit/StatsSearchTermTimeIntervalData.swift new file mode 100644 index 000000000000..af940dc313b2 --- /dev/null +++ b/Modules/Sources/WordPressKit/StatsSearchTermTimeIntervalData.swift @@ -0,0 +1,69 @@ +import Foundation +public struct StatsSearchTermTimeIntervalData { + public let period: StatsPeriodUnit + public let periodEndDate: Date + + public let totalSearchTermsCount: Int + public let hiddenSearchTermsCount: Int + public let otherSearchTermsCount: Int + public let searchTerms: [StatsSearchTerm] + + public init(period: StatsPeriodUnit, + periodEndDate: Date, + searchTerms: [StatsSearchTerm], + totalSearchTermsCount: Int, + hiddenSearchTermsCount: Int, + otherSearchTermsCount: Int) { + self.period = period + self.periodEndDate = periodEndDate + self.searchTerms = searchTerms + self.totalSearchTermsCount = totalSearchTermsCount + self.hiddenSearchTermsCount = hiddenSearchTermsCount + self.otherSearchTermsCount = otherSearchTermsCount + } +} + +public struct StatsSearchTerm { + public let term: String + public let viewsCount: Int + + public init(term: String, + viewsCount: Int) { + self.term = term + self.viewsCount = viewsCount + } +} + +extension StatsSearchTermTimeIntervalData: StatsTimeIntervalData { + public static var pathComponent: String { + return "stats/search-terms" + } + + public init?(date: Date, period: StatsPeriodUnit, jsonDictionary: [String: AnyObject]) { + guard + let unwrappedDays = type(of: self).unwrapDaysDictionary(jsonDictionary: jsonDictionary), + let totalSearchTerms = unwrappedDays["total_search_terms"] as? Int, + let hiddenSearchTerms = unwrappedDays["encrypted_search_terms"] as? Int, + let otherSearchTerms = unwrappedDays["other_search_terms"] as? Int, + let searchTermsDict = unwrappedDays["search_terms"] as? [[String: AnyObject]] + else { + return nil + } + + let searchTerms: [StatsSearchTerm] = searchTermsDict.compactMap { + guard let term = $0["term"] as? String, let views = $0["views"] as? Int else { + return nil + } + + return StatsSearchTerm(term: term, viewsCount: views) + } + + self.periodEndDate = date + self.period = period + self.totalSearchTermsCount = totalSearchTerms + self.hiddenSearchTermsCount = hiddenSearchTerms + self.otherSearchTermsCount = otherSearchTerms + self.searchTerms = searchTerms + } + +} diff --git a/Modules/Sources/WordPressKit/StatsServiceRemoteV2.swift b/Modules/Sources/WordPressKit/StatsServiceRemoteV2.swift new file mode 100644 index 000000000000..c2bc64d52e2f --- /dev/null +++ b/Modules/Sources/WordPressKit/StatsServiceRemoteV2.swift @@ -0,0 +1,509 @@ +import Foundation +import WordPressKitObjC + +// This name isn't great! After finishing the work on StatsRefresh we'll get rid of the "old" +// one and rename this to not have "V2" in it, but we want to keep the old one around +// for a while still. + +open class StatsServiceRemoteV2: ServiceRemoteWordPressComREST { + + public enum ResponseError: Error { + case decodingFailure + case emptySummary + } + + public enum MarkAsSpamResponseError: Error { + case unsuccessful + } + + public let siteID: Int + private let siteTimezone: TimeZone + + private var periodDataQueryDateFormatter: DateFormatter { + let df = DateFormatter() + df.locale = Locale(identifier: "en_US_POSIX") + df.dateFormat = "yyyy-MM-dd" + return df + } + + private var hourlyDateFormatter: DateFormatter { + let df = DateFormatter() + df.locale = Locale(identifier: "en_US_POSIX") + df.dateFormat = "yyyy-MM-dd HH:mm:ss" + return df + } + + private lazy var calendarForSite: Calendar = { + var cal = Calendar(identifier: .iso8601) + cal.timeZone = siteTimezone + return cal + }() + + public init(wordPressComRestApi api: WordPressComRestApi, siteID: Int, siteTimezone: TimeZone) { + self.siteID = siteID + self.siteTimezone = siteTimezone + super.init(wordPressComRestApi: api) + } + + /// Responsible for fetching Stats data for Insights — latest data about a site, + /// in general — not considering a specific slice of time. + /// For a possible set of returned types, see objects that conform to `StatsInsightData`. + /// - parameters: + /// - limit: Limit of how many objects you want returned for your query. Default is `10`. `0` means no limit. + public func getInsight(limit: Int = 10, + completion: @escaping ((InsightType?, Error?) -> Void)) { + let properties = InsightType.queryProperties(with: limit) as [String: AnyObject] + let pathComponent = InsightType.pathComponent + + let path = self.path(forEndpoint: "sites/\(siteID)/\(pathComponent)/", withVersion: ._1_1) + + wordPressComRESTAPI.get(path, parameters: properties, success: { (response, _) in + guard + let jsonResponse = response as? [String: AnyObject], + let insight = InsightType(jsonDictionary: jsonResponse) + else { + completion(nil, ResponseError.decodingFailure) + return + } + + completion(insight, nil) + }, failure: { (error, _) in + completion(nil, error) + }) + } + + /// Used to mark or unmark referrer as spam, depending of the current value. + /// - parameters: + /// - referrerDomain: A referrer's domain. + /// - currentValue: Current value of the `isSpam` referrer's property. + open func toggleSpamState(for referrerDomain: String, + currentValue: Bool, + success: @escaping () -> Void, + failure: @escaping (Error) -> Void) { + let path = pathForToggleSpamStateEndpoint(referrerDomain: referrerDomain, markAsSpam: !currentValue) + wordPressComRESTAPI.post(path, parameters: nil, success: { object, _ in + guard + let dictionary = object as? [String: AnyObject], + let response = MarkAsSpamResponse(dictionary: dictionary) else { + failure(ResponseError.decodingFailure) + return + } + + guard response.success else { + failure(MarkAsSpamResponseError.unsuccessful) + return + } + + success() + }, failure: { error, _ in + failure(error) + }) + } + + /// Used to fetch data about site over a specific timeframe. + /// - parameters: + /// - period: An enum representing whether either a day, a week, a month or a year worth's of data. + /// - unit: An enum representing whether the data is retuned in a day, a week, a month or a year granularity. Default is `period`. + /// - endingOn: Date on which the `period` for which data you're interested in **is ending**. + /// e.g. if you want data spanning 11-17 Feb 2019, you should pass in a period of `.week` and an + /// ending date of `Feb 17 2019`. + /// - limit: Limit of how many objects you want returned for your query. Default is `10`. `0` means no limit. + open func getData( + for period: StatsPeriodUnit, + unit: StatsPeriodUnit? = nil, + startDate: Date? = nil, + endingOn: Date, + limit: Int = 10, + summarize: Bool? = nil, + parameters: [String: String]? = nil, + completion: @escaping ((TimeStatsType?, Error?) -> Void) + ) { + let pathComponent = TimeStatsType.pathComponent + let path = self.path(forEndpoint: "sites/\(siteID)/\(pathComponent)/", withVersion: ._1_1) + + let dateFormatter = period == .hour ? hourlyDateFormatter : periodDataQueryDateFormatter + + var staticProperties = ["period": period.stringValue, + "unit": unit?.stringValue ?? period.stringValue, + "date": dateFormatter.string(from: endingOn)] as [String: AnyObject] + + if let startDate { + staticProperties["start_date"] = dateFormatter.string(from: startDate) as AnyObject + } + if let summarize { + staticProperties["summarize"] = summarize.description as NSString + } + if let parameters { + for (key, value) in parameters { + staticProperties[key] = value as NSString + } + } + + let classProperties = TimeStatsType.queryProperties(with: endingOn, period: unit ?? period, maxCount: limit) as [String: AnyObject] + + let properties = staticProperties.merging(classProperties) { val1, _ in + return val1 + } + + wordPressComRESTAPI.get(path, parameters: properties, success: { (response, _) in + guard + let jsonResponse = response as? [String: AnyObject], + let dateString = jsonResponse["date"] as? String, + let date = dateFormatter.date(from: dateString) + else { + completion(nil, ResponseError.decodingFailure) + return + } + + let periodString = jsonResponse["period"] as? String + let unitString = jsonResponse["unit"] as? String + let parsedPeriod = periodString.flatMap { StatsPeriodUnit(string: $0) } ?? period + let parsedUnit = unitString.flatMap { StatsPeriodUnit(string: $0) } ?? unit ?? period + // some responses omit this field! not a reason to fail a whole request parsing though. + + guard let timestats = TimeStatsType(date: date, period: parsedPeriod, unit: parsedUnit, jsonDictionary: jsonResponse) else { + if summarize == true { + // Some responses return `"summary": null` with no good way to + // process it without refactoring every response, hence this workaround. + completion(nil, ResponseError.emptySummary) + } else { + completion(nil, ResponseError.decodingFailure) + } + return + } + + completion(timestats, nil) + }, failure: { (error, _) in + completion(nil, error) + }) + } + + public func getDetails(forPostID postID: Int, completion: @escaping ((StatsPostDetails?, Error?) -> Void)) { + let path = self.path(forEndpoint: "sites/\(siteID)/stats/post/\(postID)/", withVersion: ._1_1) + + wordPressComRESTAPI.get(path, parameters: [:], success: { (response, _) in + guard + let jsonResponse = response as? [String: AnyObject], + let postDetails = StatsPostDetails(jsonDictionary: jsonResponse) + else { + completion(nil, ResponseError.decodingFailure) + return + } + + completion(postDetails, nil) + }, failure: { (error, _) in + completion(nil, error) + }) + } +} + +// MARK: - StatsLastPostInsight Handling + +extension StatsServiceRemoteV2 { + // "Last Post" Insights are "fun" in the way that they require multiple requests to actually create them, + // so we do this "fun" dance in a separate method. + public func getInsight(limit: Int = 10, completion: @escaping ((StatsLastPostInsight?, Error?) -> Void)) { + getLastPostInsight(completion: completion) + } + + private func getLastPostInsight(limit: Int = 10, completion: @escaping ((StatsLastPostInsight?, Error?) -> Void)) { + let properties = StatsLastPostInsight.queryProperties(with: limit) as [String: AnyObject] + let pathComponent = StatsLastPostInsight.pathComponent + let path = self.path(forEndpoint: "sites/\(siteID)/\(pathComponent)", withVersion: ._1_1) + + wordPressComRESTAPI.get(path, parameters: properties, success: { (response, _) in + guard let jsonResponse = response as? [String: AnyObject], + let postCount = jsonResponse["found"] as? Int else { + completion(nil, ResponseError.decodingFailure) + return + } + + guard postCount > 0 else { + completion(nil, nil) + return + } + + guard + let posts = jsonResponse["posts"] as? [[String: AnyObject]], + let post = posts.first, + let postID = post["ID"] as? Int else { + completion(nil, ResponseError.decodingFailure) + return + } + + self.getPostViews(for: postID) { (views, _) in + guard + let views, + let insight = StatsLastPostInsight(jsonDictionary: post, views: views) else { + completion(nil, ResponseError.decodingFailure) + return + + } + + completion(insight, nil) + } + }, failure: {(error, _) in + completion(nil, error) + }) + } + + private func getPostViews(`for` postID: Int, completion: @escaping ((Int?, Error?) -> Void)) { + let parameters = ["fields": "views" as AnyObject] + + let path = self.path(forEndpoint: "sites/\(siteID)/stats/post/\(postID)", withVersion: ._1_1) + + wordPressComRESTAPI.get(path, + parameters: parameters, + success: { (response, _) in + guard + let jsonResponse = response as? [String: AnyObject], + let views = jsonResponse["views"] as? Int else { + completion(nil, ResponseError.decodingFailure) + return + } + completion(views, nil) + }, failure: { (error, _) in + completion(nil, error) + } + ) + } +} + +// MARK: - StatsPublishedPostsTimeIntervalData Handling + +extension StatsServiceRemoteV2 { + + // StatsPublishedPostsTimeIntervalData hit a different endpoint and with different parameters + // then the rest of the time-based types — we need to handle them separately here. + public func getData(for period: StatsPeriodUnit, + endingOn: Date, + limit: Int = 10, + completion: @escaping ((StatsPublishedPostsTimeIntervalData?, Error?) -> Void)) { + let pathComponent = StatsLastPostInsight.pathComponent + let path = self.path(forEndpoint: "sites/\(siteID)/\(pathComponent)", withVersion: ._1_1) + + let properties = ["number": limit, + "fields": "ID, title, URL", + "after": ISO8601DateFormatter().string(from: startDate(for: period, endDate: endingOn)), + "before": ISO8601DateFormatter().string(from: endingOn)] as [String: AnyObject] + + wordPressComRESTAPI.get(path, + parameters: properties, + success: { (response, _) in + guard + let jsonResponse = response as? [String: AnyObject], + let response = StatsPublishedPostsTimeIntervalData(date: endingOn, period: period, unit: nil, jsonDictionary: jsonResponse) else { + completion(nil, ResponseError.decodingFailure) + return + } + completion(response, nil) + }, failure: { (error, _) in + completion(nil, error) + } + ) + } + + private func startDate(for period: StatsPeriodUnit, endDate: Date) -> Date { + switch period { + case .hour: + assertionFailure("unsupported period: \(period)") + return calendarForSite.startOfDay(for: endDate) + case .day: + return calendarForSite.startOfDay(for: endDate) + case .week: + let weekAgo = calendarForSite.date(byAdding: .day, value: -6, to: endDate)! + return calendarForSite.startOfDay(for: weekAgo) + case .month: + let monthAgo = calendarForSite.date(byAdding: .month, value: -1, to: endDate)! + let firstOfMonth = calendarForSite.date(bySetting: .day, value: 1, of: monthAgo)! + return calendarForSite.startOfDay(for: firstOfMonth) + case .year: + let yearAgo = calendarForSite.date(byAdding: .year, value: -1, to: endDate)! + let january = calendarForSite.date(bySetting: .month, value: 1, of: yearAgo)! + let jan1 = calendarForSite.date(bySetting: .day, value: 1, of: january)! + return calendarForSite.startOfDay(for: jan1) + } + } + +} + +// MARK: - Mark referrer as spam helpers + +private extension StatsServiceRemoteV2 { + func pathForToggleSpamStateEndpoint(referrerDomain: String, markAsSpam: Bool) -> String { + let action = markAsSpam ? "new" : "delete" + return self.path(forEndpoint: "sites/\(siteID)/stats/referrers/spam/\(action)?domain=\(referrerDomain)", withVersion: ._1_1) + } + + struct MarkAsSpamResponse { + let success: Bool + + init?(dictionary: [String: AnyObject]) { + guard let value = dictionary["success"] as? Bool else { + return nil + } + self.success = value + } + } +} + +// MARK: - Emails Summary + +public extension StatsServiceRemoteV2 { + func getData(quantity: Int, + sortField: StatsEmailsSummaryData.SortField = .opens, + sortOrder: StatsEmailsSummaryData.SortOrder = .descending, + completion: @escaping ((Result) -> Void)) { + let pathComponent = StatsEmailsSummaryData.pathComponent + let path = self.path(forEndpoint: "sites/\(siteID)/\(pathComponent)/", withVersion: ._1_1) + let properties = StatsEmailsSummaryData.queryProperties(quantity: quantity, sortField: sortField, sortOrder: sortOrder) as [String: AnyObject] + + wordPressComRESTAPI.get(path, parameters: properties, success: { (response, _) in + guard let jsonResponse = response as? [String: AnyObject], + let emailsSummaryData = StatsEmailsSummaryData(jsonDictionary: jsonResponse) + else { + completion(.failure(ResponseError.decodingFailure)) + return + } + + completion(.success(emailsSummaryData)) + }, failure: { (error, _) in + completion(.failure(error)) + }) + } +} + +// MARK: - Email Opens + +public extension StatsServiceRemoteV2 { + func getEmailOpens(for postID: Int, completion: @escaping ((StatsEmailOpensData?, Error?) -> Void)) { + let path = self.path(forEndpoint: "sites/\(siteID)/stats/opens/emails/\(postID)/rate", withVersion: ._1_1) + + wordPressComRESTAPI.get(path, parameters: [:], success: { (response, _) in + guard + let jsonResponse = response as? [String: AnyObject], + let emailOpensData = StatsEmailOpensData(jsonDictionary: jsonResponse) + else { + completion(nil, ResponseError.decodingFailure) + return + } + + completion(emailOpensData, nil) + }, failure: { (error, _) in + completion(nil, error) + }) + } +} + +// This serves both as a way to get the query properties in a "nice" way, +// but also as a way to narrow down the generic type in `getInsight(completion:)` method. +public protocol StatsInsightData { + static func queryProperties(with maxCount: Int) -> [String: String] + static var pathComponent: String { get } + + init?(jsonDictionary: [String: AnyObject]) +} + +public protocol StatsTimeIntervalData { + static var pathComponent: String { get } + + var period: StatsPeriodUnit { get } + var unit: StatsPeriodUnit? { get } + var periodEndDate: Date { get } + + init?(date: Date, period: StatsPeriodUnit, jsonDictionary: [String: AnyObject]) + init?(date: Date, period: StatsPeriodUnit, unit: StatsPeriodUnit?, jsonDictionary: [String: AnyObject]) + + static func queryProperties(with date: Date, period: StatsPeriodUnit, maxCount: Int) -> [String: String] +} + +extension StatsTimeIntervalData { + + public var unit: StatsPeriodUnit? { + return nil + } + + public static func queryProperties(with date: Date, period: StatsPeriodUnit, maxCount: Int) -> [String: String] { + return ["max": String(maxCount)] + } + + public init?(date: Date, period: StatsPeriodUnit, unit: StatsPeriodUnit?, jsonDictionary: [String: AnyObject]) { + self.init(date: date, period: period, jsonDictionary: jsonDictionary) + } + + // Most of the responses for time data come in a unwieldy format, that requires awkwkard unwrapping + // at the call-site — unfortunately not _all of them_, which means we can't just do it at the request level. + static func unwrapDaysDictionary(jsonDictionary: [String: AnyObject]) -> [String: AnyObject]? { + if let summary = jsonDictionary["summary"] as? [String: AnyObject] { + return summary + } + if let days = jsonDictionary["days"] as? [String: AnyObject], + let firstKey = days.keys.first, + let firstDay = days[firstKey] as? [String: AnyObject] { + return firstDay + } + return nil + } + +} + +// We'll bring `StatsPeriodUnit` into this file when the "old" `WPStatsServiceRemote` gets removed. +// For now we can piggy-back off the old type and add this as an extension. +public extension StatsPeriodUnit { + var stringValue: String { + switch self { + case .hour: + return "hour" + case .day: + return "day" + case .week: + return "week" + case .month: + return "month" + case .year: + return "year" + } + } + + init?(string: String) { + switch string { + case "hour": + self = .hour + case "day": + self = .day + case "week": + self = .week + case "month": + self = .month + case "year": + self = .year + default: + return nil + } + } +} + +extension StatsInsightData { + + // A big chunk of those use the same endpoint and queryProperties.. Let's simplify the protocol conformance in those cases. + + public static func queryProperties(with maxCount: Int) -> [String: String] { + return ["max": String(maxCount)] + } + + public static var pathComponent: String { + return "stats/" + } +} + +public extension StatsInsightData where Self: Codable { + init?(jsonDictionary: [String: AnyObject]) { + do { + let jsonData = try JSONSerialization.data(withJSONObject: jsonDictionary, options: []) + let decoder = JSONDecoder() + self = try decoder.decode(Self.self, from: jsonData) + } catch { + return nil + } + } +} diff --git a/Modules/Sources/WordPressKit/StatsSiteMetricsResponse.swift b/Modules/Sources/WordPressKit/StatsSiteMetricsResponse.swift new file mode 100644 index 000000000000..7a9f9da2a340 --- /dev/null +++ b/Modules/Sources/WordPressKit/StatsSiteMetricsResponse.swift @@ -0,0 +1,108 @@ +import Foundation + +public struct StatsSiteMetricsResponse { + public var period: StatsPeriodUnit + public var periodEndDate: Date + public let data: [PeriodData] + + public enum Metric: String, CaseIterable { + case views + case visitors + case likes + case comments + case posts + } + + public struct PeriodData { + /// Periods date in the site timezone. + public var date: Date + public var views: Int? + public var visitors: Int? + public var likes: Int? + public var comments: Int? + public var posts: Int? + + public subscript(metric: Metric) -> Int? { + switch metric { + case .views: views + case .visitors: visitors + case .likes: likes + case .comments: comments + case .posts: posts + } + } + } +} + +extension StatsSiteMetricsResponse: StatsTimeIntervalData { + public static var pathComponent: String { + "stats/visits" + } + + public static func queryProperties(with date: Date, period: StatsPeriodUnit, maxCount: Int) -> [String: String] { + return [ + "unit": period.stringValue, + "quantity": String(maxCount), + "stat_fields": Metric.allCases.map(\.rawValue).joined(separator: ",") + ] + } + + public init?(date: Date, period: StatsPeriodUnit, jsonDictionary: [String: AnyObject]) { + self.init(date: date, period: period, unit: nil, jsonDictionary: jsonDictionary) + } + + public init?(date: Date, period: StatsPeriodUnit, unit: StatsPeriodUnit?, jsonDictionary: [String: AnyObject]) { + guard let fields = jsonDictionary["fields"] as? [String], + let data = jsonDictionary["data"] as? [[Any]] else { + return nil + } + + guard let periodIndex = fields.firstIndex(of: "period") else { + return nil + } + + self.period = period + self.periodEndDate = date + + let indices = ( + views: fields.firstIndex(of: Metric.views.rawValue), + visitors: fields.firstIndex(of: Metric.visitors.rawValue), + likes: fields.firstIndex(of: Metric.likes.rawValue), + comments: fields.firstIndex(of: Metric.comments.rawValue), + posts: fields.firstIndex(of: Metric.posts.rawValue) + ) + + let dateFormatter = makeDateFormatter(for: period) + + self.data = data.compactMap { data in + guard let date = dateFormatter.date(from: data[periodIndex] as? String ?? "") else { + return nil + } + func getValue(at index: Int?) -> Int? { + guard let index else { return nil } + return data[index] as? Int + } + return PeriodData( + date: date, + views: getValue(at: indices.views), + visitors: getValue(at: indices.visitors), + likes: getValue(at: indices.likes), + comments: getValue(at: indices.comments), + posts: getValue(at: indices.posts) + ) + } + } +} + +private func makeDateFormatter(for unit: StatsPeriodUnit) -> DateFormatter { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = { + switch unit { + case .hour: "yyyy-MM-dd HH:mm:ss" + case .week: "yyyy'W'MM'W'dd" + case .day, .month, .year: "yyyy-MM-dd" + } + }() + return formatter +} diff --git a/Modules/Sources/WordPressKit/StatsSubscribersSummaryData.swift b/Modules/Sources/WordPressKit/StatsSubscribersSummaryData.swift new file mode 100644 index 000000000000..db89bfc0efff --- /dev/null +++ b/Modules/Sources/WordPressKit/StatsSubscribersSummaryData.swift @@ -0,0 +1,94 @@ +import Foundation + +public struct StatsSubscribersSummaryData: Equatable { + public let history: [SubscriberData] + public let period: StatsPeriodUnit + public let periodEndDate: Date + + public init(history: [SubscriberData], period: StatsPeriodUnit, periodEndDate: Date) { + self.history = history + self.period = period + self.periodEndDate = periodEndDate + } +} + +extension StatsSubscribersSummaryData: StatsTimeIntervalData { + public static var pathComponent: String { + return "stats/subscribers" + } + + static var hourlyDateFormatter: DateFormatter { + let df = DateFormatter() + df.locale = Locale(identifier: "en_US_POSIX") + df.dateFormat = "yyyy-MM-dd HH:mm:ss" + return df + } + + static var dateFormatter: DateFormatter = { + let df = DateFormatter() + df.locale = Locale(identifier: "en_US_POS") + df.dateFormat = "yyyy-MM-dd" + return df + }() + + static var weeksDateFormatter: DateFormatter = { + let df = DateFormatter() + df.locale = Locale(identifier: "en_US_POS") + df.dateFormat = "yyyy'W'MM'W'dd" + return df + }() + + public struct SubscriberData: Equatable { + public let date: Date + public let count: Int + + public init(date: Date, count: Int) { + self.date = date + self.count = count + } + } + + public init?(date: Date, period: StatsPeriodUnit, jsonDictionary: [String: AnyObject]) { + guard + let fields = jsonDictionary["fields"] as? [String], + let data = jsonDictionary["data"] as? [[Any]], + let dateIndex = fields.firstIndex(of: "period"), + let countIndex = fields.firstIndex(of: "subscribers") + else { + return nil + } + + let history: [SubscriberData?] = data.map { elements in + guard elements.indices.contains(dateIndex) && elements.indices.contains(countIndex), + let dateString = elements[dateIndex] as? String, + let date = StatsSubscribersSummaryData.parsedDate(from: dateString, for: period) + else { + return nil + } + + let count = elements[countIndex] as? Int ?? 0 + + return SubscriberData(date: date, count: count) + } + + let sorted = history.compactMap { $0 }.sorted { $0.date < $1.date } + + self = .init(history: sorted, period: period, periodEndDate: date) + } + + private static func parsedDate(from dateString: String, for period: StatsPeriodUnit) -> Date? { + switch period { + case .hour: + // Example: "2025-07-17 09:00:00" (in a site timezone) + return self.hourlyDateFormatter.date(from: dateString) + case .week: + return self.weeksDateFormatter.date(from: dateString) + case .day, .month, .year: + return self.dateFormatter.date(from: dateString) + } + } + + public static func queryProperties(with date: Date, period: StatsPeriodUnit, maxCount: Int) -> [String: String] { + return ["quantity": String(maxCount), "unit": period.stringValue] + } +} diff --git a/Modules/Sources/WordPressKit/StatsSummaryTimeIntervalData.swift b/Modules/Sources/WordPressKit/StatsSummaryTimeIntervalData.swift new file mode 100644 index 000000000000..5ce94bc353cc --- /dev/null +++ b/Modules/Sources/WordPressKit/StatsSummaryTimeIntervalData.swift @@ -0,0 +1,277 @@ +import Foundation + +@frozen public enum StatsPeriodUnit: Int { + case day + case week + case month + case year + case hour +} + +@frozen public enum StatsSummaryType: Int { + case views + case visitors + case likes + case comments +} + +public struct StatsSummaryTimeIntervalData { + public let period: StatsPeriodUnit + public let unit: StatsPeriodUnit? + public let periodEndDate: Date + + public let summaryData: [StatsSummaryData] + + public init(period: StatsPeriodUnit, + unit: StatsPeriodUnit?, + periodEndDate: Date, + summaryData: [StatsSummaryData]) { + self.period = period + self.unit = unit + self.periodEndDate = periodEndDate + self.summaryData = summaryData + } +} + +public struct StatsSummaryData { + public let period: StatsPeriodUnit + public let periodStartDate: Date + + public let viewsCount: Int + public let visitorsCount: Int + public let likesCount: Int + public let commentsCount: Int + + public init(period: StatsPeriodUnit, + periodStartDate: Date, + viewsCount: Int, + visitorsCount: Int, + likesCount: Int, + commentsCount: Int) { + self.period = period + self.periodStartDate = periodStartDate + self.viewsCount = viewsCount + self.visitorsCount = visitorsCount + self.likesCount = likesCount + self.commentsCount = commentsCount + } +} + +extension StatsSummaryTimeIntervalData: StatsTimeIntervalData { + public static var pathComponent: String { + return "stats/visits" + } + + public static func queryProperties(with date: Date, period: StatsPeriodUnit, maxCount: Int) -> [String: String] { + return ["unit": period.stringValue, + "quantity": String(maxCount), + "stat_fields": "views,visitors,comments,likes"] + } + + public init?(date: Date, period: StatsPeriodUnit, jsonDictionary: [String: AnyObject]) { + self.init(date: date, period: period, unit: nil, jsonDictionary: jsonDictionary) + } + + public init?(date: Date, period: StatsPeriodUnit, unit: StatsPeriodUnit?, jsonDictionary: [String: AnyObject]) { + guard + let fieldsArray = jsonDictionary["fields"] as? [String], + let data = jsonDictionary["data"] as? [[Any]] + else { + return nil + } + + // The shape of data for this response is somewhat unconventional. + // (you might want to take a peek at included tests fixtures files `stats-visits-*.json`) + // There's a `fields` arrray with strings that correspond to requested properties + // (e.g. something like ["period", "views", "visitors"]. + // The actual data we're after is then contained in the `data`... array of arrays? + // The "inner" arrays contain multiple entries, whose indexes correspond to + // the positions of the appropriate keys in the `fields` array, so in our example the array looks something like this: + // [["2019-01-01", 9001, 1234], ["2019-02-01", 1234, 1234]], where the first object in the "inner" array + // is the `period`, second is `views`, etc. + + guard + let periodIndex = fieldsArray.firstIndex(of: "period"), + let viewsIndex = fieldsArray.firstIndex(of: "views"), + let visitorsIndex = fieldsArray.firstIndex(of: "visitors"), + let commentsIndex = fieldsArray.firstIndex(of: "comments"), + let likesIndex = fieldsArray.firstIndex(of: "likes") + else { + return nil + } + + self.period = period + self.unit = unit + self.periodEndDate = date + self.summaryData = data.compactMap { StatsSummaryData(dataArray: $0, + period: unit ?? period, + periodIndex: periodIndex, + viewsIndex: viewsIndex, + visitorsIndex: visitorsIndex, + likesIndex: likesIndex, + commentsIndex: commentsIndex) } + } +} + +private extension StatsSummaryData { + init?(dataArray: [Any], + period: StatsPeriodUnit, + periodIndex: Int, + viewsIndex: Int?, + visitorsIndex: Int?, + likesIndex: Int?, + commentsIndex: Int?) { + + guard + let periodString = dataArray[periodIndex] as? String, + let periodStart = type(of: self).parsedDate(from: periodString, for: period) else { + return nil + } + + let viewsCount: Int + let visitorsCount: Int + let likesCount: Int + let commentsCount: Int + + if let viewsIndex { + guard let count = dataArray[viewsIndex] as? Int else { + return nil + } + viewsCount = count + } else { + viewsCount = 0 + } + + if let visitorsIndex { + guard let count = dataArray[visitorsIndex] as? Int else { + return nil + } + visitorsCount = count + } else { + visitorsCount = 0 + } + + if let likesIndex { + guard let count = dataArray[likesIndex] as? Int else { + return nil + } + likesCount = count + } else { + likesCount = 0 + } + + if let commentsIndex { + guard let count = dataArray[commentsIndex] as? Int else { + return nil + } + commentsCount = count + } else { + commentsCount = 0 + } + + self.period = period + self.periodStartDate = periodStart + + self.viewsCount = viewsCount + self.visitorsCount = visitorsCount + self.likesCount = likesCount + self.commentsCount = commentsCount + } + + static func parsedDate(from dateString: String, for period: StatsPeriodUnit) -> Date? { + switch period { + case .hour: + assertionFailure("Unsupported time period") + return nil + case .week: + return self.weeksDateFormatter.date(from: dateString) + case .day, .month, .year: + return self.regularDateFormatter.date(from: dateString) + } + } + + static var regularDateFormatter: DateFormatter { + let df = DateFormatter() + df.locale = Locale(identifier: "en_US_POS") + df.dateFormat = "yyyy-MM-dd" + return df + } + + // We have our own handrolled date format for data broken up on week basis. + // Example dates in this format are `2019W02W18` or `2019W02W11`. + // The structure is `aaaaWbbWcc`, where: + // - `aaaa` is four-digit year number, + // - `bb` is two-digit month number + // - `cc` is two-digit day number + // Note that in contrast to almost every other date used in Stats, those dates + // represent the _beginning_ of the period they're applying to, e.g. + // data set for `2019W02W18` is containing data for the period of Feb 18 - Feb 24 2019. + private static var weeksDateFormatter: DateFormatter { + let df = DateFormatter() + df.locale = Locale(identifier: "en_US_POS") + df.dateFormat = "yyyy'W'MM'W'dd" + return df + } +} + +/// So this is very awkward and neccessiated by our API. Turns out, calculating likes +/// for long periods of times (months/years) on large sites takes _ages_ (up to a minute sometimes). +/// Thankfully, calculating views/visitors/comments takes a much shorter time. (~2s, which is still suuuuuper long, but acceptable.) +/// We don't want to wait a whole minute to display the rest of the data, so we fetch the likes separately. +public struct StatsLikesSummaryTimeIntervalData { + + public let period: StatsPeriodUnit + public let periodEndDate: Date + + public let summaryData: [StatsSummaryData] + + public init(period: StatsPeriodUnit, + periodEndDate: Date, + summaryData: [StatsSummaryData]) { + self.period = period + self.periodEndDate = periodEndDate + self.summaryData = summaryData + } +} + +extension StatsLikesSummaryTimeIntervalData: StatsTimeIntervalData { + + public static var pathComponent: String { + return "stats/visits" + } + + public static func queryProperties(with date: Date, period: StatsPeriodUnit, maxCount: Int) -> [String: String] { + return ["unit": period.stringValue, + "quantity": String(maxCount), + "stat_fields": "likes"] + } + + public init?(date: Date, period: StatsPeriodUnit, jsonDictionary: [String: AnyObject]) { + self.init(date: date, period: period, unit: nil, jsonDictionary: jsonDictionary) + } + + public init?(date: Date, period: StatsPeriodUnit, unit: StatsPeriodUnit?, jsonDictionary: [String: AnyObject]) { + guard + let fieldsArray = jsonDictionary["fields"] as? [String], + let data = jsonDictionary["data"] as? [[Any]] + else { + return nil + } + + guard + let periodIndex = fieldsArray.firstIndex(of: "period"), + let likesIndex = fieldsArray.firstIndex(of: "likes") else { + return nil + } + + self.period = period + self.periodEndDate = date + self.summaryData = data.compactMap { StatsSummaryData(dataArray: $0, + period: unit ?? period, + periodIndex: periodIndex, + viewsIndex: nil, + visitorsIndex: nil, + likesIndex: likesIndex, + commentsIndex: nil) } + } +} diff --git a/Modules/Sources/WordPressKit/StatsTagsAndCategoriesInsight.swift b/Modules/Sources/WordPressKit/StatsTagsAndCategoriesInsight.swift new file mode 100644 index 000000000000..07f29563b28a --- /dev/null +++ b/Modules/Sources/WordPressKit/StatsTagsAndCategoriesInsight.swift @@ -0,0 +1,86 @@ +import Foundation +public struct StatsTagsAndCategoriesInsight: Codable { + public let topTagsAndCategories: [StatsTagAndCategory] + + private enum CodingKeys: String, CodingKey { + case topTagsAndCategories = "tags" + } +} + +extension StatsTagsAndCategoriesInsight: StatsInsightData { + public static var pathComponent: String { + return "stats/tags" + } +} + +public struct StatsTagAndCategory: Codable { + @frozen public enum Kind: String, Codable { + case tag + case category + case folder + } + + public let name: String + public let kind: Kind + public let url: URL? + public let viewsCount: Int? + public let children: [StatsTagAndCategory] + + private enum CodingKeys: String, CodingKey { + case name + case kind = "type" + case url = "link" + case viewsCount = "views" + case children = "tags" + } + + public init(name: String, kind: Kind, url: URL?, viewsCount: Int?, children: [StatsTagAndCategory]) { + self.name = name + self.kind = kind + self.url = url + self.viewsCount = viewsCount + self.children = children + } +} + +extension StatsTagAndCategory { + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + let innerTags = try container.decodeIfPresent([StatsTagAndCategory].self, forKey: .children) ?? [] + let viewsCount = (try? container.decodeIfPresent(Int.self, forKey: .viewsCount)) ?? 0 + + // This gets kinda complicated. The API collects some tags/categories + // into groups, and we have to handle that. + if innerTags.isEmpty { + self.init( + name: try container.decode(String.self, forKey: .name), + kind: try container.decode(Kind.self, forKey: .kind), + url: try container.decodeIfPresent(URL.self, forKey: .url), + viewsCount: nil, + children: [] + ) + } else if innerTags.count == 1, let tag = innerTags.first { + self.init(singleTag: tag, viewsCount: viewsCount) + } else { + let mappedChildren = innerTags.compactMap { StatsTagAndCategory(singleTag: $0) } + let label = mappedChildren.map { $0.name }.joined(separator: ", ") + self.init(name: label, kind: .folder, url: nil, viewsCount: viewsCount, children: mappedChildren) + } + } + + init(singleTag tag: StatsTagAndCategory, viewsCount: Int? = 0) { + let kind: Kind + + switch tag.kind { + case .category: + kind = .category + case .tag: + kind = .tag + default: + kind = .category + } + + self.init(name: tag.name, kind: kind, url: tag.url, viewsCount: viewsCount, children: []) + } +} diff --git a/Modules/Sources/WordPressKit/StatsTodayInsight.swift b/Modules/Sources/WordPressKit/StatsTodayInsight.swift new file mode 100644 index 000000000000..beade15a0a8a --- /dev/null +++ b/Modules/Sources/WordPressKit/StatsTodayInsight.swift @@ -0,0 +1,40 @@ +import Foundation +public struct StatsTodayInsight: Codable { + public let viewsCount: Int + public let visitorsCount: Int + public let likesCount: Int + public let commentsCount: Int + + public init(viewsCount: Int, + visitorsCount: Int, + likesCount: Int, + commentsCount: Int) { + self.viewsCount = viewsCount + self.visitorsCount = visitorsCount + self.likesCount = likesCount + self.commentsCount = commentsCount + } +} + +extension StatsTodayInsight: StatsInsightData { + + // MARK: - StatsInsightData Conformance + public static var pathComponent: String { + return "stats/summary" + } + + private enum CodingKeys: String, CodingKey { + case viewsCount = "views" + case visitorsCount = "visitors" + case likesCount = "likes" + case commentsCount = "comments" + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + viewsCount = (try? container.decodeIfPresent(Int.self, forKey: .viewsCount)) ?? 0 + visitorsCount = (try? container.decodeIfPresent(Int.self, forKey: .visitorsCount)) ?? 0 + likesCount = (try? container.decodeIfPresent(Int.self, forKey: .likesCount)) ?? 0 + commentsCount = (try? container.decodeIfPresent(Int.self, forKey: .commentsCount)) ?? 0 + } +} diff --git a/Modules/Sources/WordPressKit/StatsTopAuthorsTimeIntervalData.swift b/Modules/Sources/WordPressKit/StatsTopAuthorsTimeIntervalData.swift new file mode 100644 index 000000000000..9d6ace2a97a0 --- /dev/null +++ b/Modules/Sources/WordPressKit/StatsTopAuthorsTimeIntervalData.swift @@ -0,0 +1,143 @@ +import Foundation +public struct StatsTopAuthorsTimeIntervalData { + public let period: StatsPeriodUnit + public let periodEndDate: Date + + public let topAuthors: [StatsTopAuthor] + + public init(period: StatsPeriodUnit, + periodEndDate: Date, + topAuthors: [StatsTopAuthor]) { + self.period = period + self.periodEndDate = periodEndDate + self.topAuthors = topAuthors + } +} + +public struct StatsTopAuthor { + public let name: String + public let iconURL: URL? + public let viewsCount: Int + public let posts: [StatsTopPost] + + public init(name: String, + iconURL: URL?, + viewsCount: Int, + posts: [StatsTopPost]) { + self.name = name + self.iconURL = iconURL + self.viewsCount = viewsCount + self.posts = posts + } +} + +public struct StatsTopPost { + + @frozen public enum Kind { + case unknown + case post + case page + case homepage + } + + public let title: String + public var date: String? + public let postID: Int + public let postURL: URL? + public let viewsCount: Int + public let kind: Kind + + public init(title: String, + date: String?, + postID: Int, + postURL: URL?, + viewsCount: Int, + kind: Kind) { + self.title = title + self.date = date + self.postID = postID + self.postURL = postURL + self.viewsCount = viewsCount + self.kind = kind + } +} + +extension StatsTopAuthorsTimeIntervalData: StatsTimeIntervalData { + public static var pathComponent: String { + return "stats/top-authors/" + } + + public init?(date: Date, period: StatsPeriodUnit, jsonDictionary: [String: AnyObject]) { + guard + let unwrappedDays = type(of: self).unwrapDaysDictionary(jsonDictionary: jsonDictionary), + let authors = unwrappedDays["authors"] as? [[String: AnyObject]] + else { + return nil + } + + self.period = period + self.periodEndDate = date + self.topAuthors = authors.compactMap { StatsTopAuthor(jsonDictionary: $0) } + } +} + +extension StatsTopAuthor { + init?(jsonDictionary: [String: AnyObject]) { + guard + let name = jsonDictionary["name"] as? String, + let views = jsonDictionary["views"] as? Int, + let avatar = jsonDictionary["avatar"] as? String, + let posts = jsonDictionary["posts"] as? [[String: AnyObject]] + else { + return nil + } + + let url: URL? + if var components = URLComponents(string: avatar) { + components.query = "d=mm&s=60" + url = components.url + } else { + url = nil + } + + let mappedPosts = posts.compactMap { StatsTopPost(jsonDictionary: $0) } + + self.name = name + self.viewsCount = views + self.iconURL = url + self.posts = mappedPosts + + } +} + +extension StatsTopPost { + init?(jsonDictionary: [String: AnyObject]) { + guard + let title = jsonDictionary["title"] as? String, + let postID = jsonDictionary["id"] as? Int, + let viewsCount = jsonDictionary["views"] as? Int, + let postURL = jsonDictionary["url"] as? String + else { + return nil + } + + self.title = title + self.postID = postID + self.viewsCount = viewsCount + self.postURL = URL(string: postURL) + self.kind = type(of: self).kind(from: jsonDictionary["type"] as? String) + } + + static func kind(from kindString: String?) -> Kind { + switch kindString { + case "post": + return .post + case "homepage": + return .homepage + case "page": + return .page + default: + return .unknown + } + } +} diff --git a/Modules/Sources/WordPressKit/StatsTopClicksTimeIntervalData.swift b/Modules/Sources/WordPressKit/StatsTopClicksTimeIntervalData.swift new file mode 100644 index 000000000000..e270df463aa5 --- /dev/null +++ b/Modules/Sources/WordPressKit/StatsTopClicksTimeIntervalData.swift @@ -0,0 +1,95 @@ +import Foundation +public struct StatsTopClicksTimeIntervalData { + public let period: StatsPeriodUnit + public let periodEndDate: Date + + public let totalClicksCount: Int + public let otherClicksCount: Int + + public let clicks: [StatsClick] + + public init(period: StatsPeriodUnit, + periodEndDate: Date, + clicks: [StatsClick], + totalClicksCount: Int, + otherClicksCount: Int) { + self.period = period + self.periodEndDate = periodEndDate + self.clicks = clicks + self.totalClicksCount = totalClicksCount + self.otherClicksCount = otherClicksCount + } +} + +public struct StatsClick { + public let title: String + public let clicksCount: Int + public let clickedURL: URL? + public let iconURL: URL? + + public let children: [StatsClick] + + public init(title: String, + clicksCount: Int, + clickedURL: URL?, + iconURL: URL?, + children: [StatsClick]) { + self.title = title + self.clicksCount = clicksCount + self.clickedURL = clickedURL + self.iconURL = iconURL + self.children = children + } +} + +extension StatsTopClicksTimeIntervalData: StatsTimeIntervalData { + public static var pathComponent: String { + return "stats/clicks" + } + + public init?(date: Date, period: StatsPeriodUnit, jsonDictionary: [String: AnyObject]) { + guard + let unwrappedDays = type(of: self).unwrapDaysDictionary(jsonDictionary: jsonDictionary), + let clicks = unwrappedDays["clicks"] as? [[String: AnyObject]] + else { + return nil + } + + let totalClicks = unwrappedDays["total_clicks"] as? Int ?? 0 + let otherClicks = unwrappedDays["other_clicks"] as? Int ?? 0 + + self.period = period + self.periodEndDate = date + self.totalClicksCount = totalClicks + self.otherClicksCount = otherClicks + self.clicks = clicks.compactMap { StatsClick(jsonDictionary: $0) } + } +} + +extension StatsClick { + init?(jsonDictionary: [String: AnyObject]) { + guard + let title = jsonDictionary["name"] as? String, + let clicksCount = jsonDictionary["views"] as? Int + else { + return nil + } + + let children: [StatsClick] + + if let childrenJSON = jsonDictionary["children"] as? [[String: AnyObject]] { + children = childrenJSON.compactMap { StatsClick(jsonDictionary: $0) } + } else { + children = [] + } + + let icon = jsonDictionary["icon"] as? String + let urlString = jsonDictionary["url"] as? String + + self.title = title + self.clicksCount = clicksCount + self.clickedURL = urlString.flatMap { URL(string: $0) } + self.iconURL = icon.flatMap { URL(string: $0) } + self.children = children + } +} diff --git a/Modules/Sources/WordPressKit/StatsTopCountryTimeIntervalData.swift b/Modules/Sources/WordPressKit/StatsTopCountryTimeIntervalData.swift new file mode 100644 index 000000000000..9ca0703e9a1d --- /dev/null +++ b/Modules/Sources/WordPressKit/StatsTopCountryTimeIntervalData.swift @@ -0,0 +1,88 @@ +import Foundation +public struct StatsTopCountryTimeIntervalData { + public let period: StatsPeriodUnit + public let periodEndDate: Date + + public let totalViewsCount: Int + public let otherViewsCount: Int + + public let countries: [StatsCountry] + + public init(period: StatsPeriodUnit, + periodEndDate: Date, + countries: [StatsCountry], + totalViewsCount: Int, + otherViewsCount: Int) { + self.period = period + self.periodEndDate = periodEndDate + self.countries = countries + self.totalViewsCount = totalViewsCount + self.otherViewsCount = otherViewsCount + } +} + +public struct StatsCountry { + public let name: String + public let code: String + public let viewsCount: Int + + public init(name: String, + code: String, + viewsCount: Int) { + self.name = name + self.code = code + self.viewsCount = viewsCount + } +} + +extension StatsTopCountryTimeIntervalData: StatsTimeIntervalData { + public static var pathComponent: String { + return "stats/country-views" + } + + public init?(date: Date, period: StatsPeriodUnit, jsonDictionary: [String: AnyObject]) { + guard + let unwrappedDays = type(of: self).unwrapDaysDictionary(jsonDictionary: jsonDictionary), + let countriesViews = unwrappedDays["views"] as? [[String: AnyObject]] + else { + return nil + } + + let countryInfo = jsonDictionary["country-info"] as? [String: AnyObject] ?? [:] + let totalViews = unwrappedDays["total_views"] as? Int ?? 0 + let otherViews = unwrappedDays["other_views"] as? Int ?? 0 + + self.periodEndDate = date + self.period = period + + self.totalViewsCount = totalViews + self.otherViewsCount = otherViews + self.countries = countriesViews.compactMap { StatsCountry(jsonDictionary: $0, countryInfo: countryInfo) } + } + +} + +extension StatsCountry { + init?(jsonDictionary: [String: AnyObject], countryInfo: [String: AnyObject]) { + guard + let viewsCount = jsonDictionary["views"] as? Int, + let countryCode = jsonDictionary["country_code"] as? String + else { + return nil + } + + let name: String + + if + let countryDict = countryInfo[countryCode] as? [String: AnyObject], + let countryName = countryDict["country_full"] as? String { + name = countryName + } else { + name = countryCode + } + + self.viewsCount = viewsCount + self.code = countryCode + self.name = name + } +} diff --git a/Modules/Sources/WordPressKit/StatsTopPostsTimeIntervalData.swift b/Modules/Sources/WordPressKit/StatsTopPostsTimeIntervalData.swift new file mode 100644 index 000000000000..6a3ad2ff9f7f --- /dev/null +++ b/Modules/Sources/WordPressKit/StatsTopPostsTimeIntervalData.swift @@ -0,0 +1,70 @@ +import Foundation +public struct StatsTopPostsTimeIntervalData { + public let period: StatsPeriodUnit + public let periodEndDate: Date + + public let totalViewsCount: Int + public let otherViewsCount: Int + public let topPosts: [StatsTopPost] + + public init(period: StatsPeriodUnit, + periodEndDate: Date, + topPosts: [StatsTopPost], + totalViewsCount: Int, + otherViewsCount: Int) { + self.period = period + self.periodEndDate = periodEndDate + self.topPosts = topPosts + self.totalViewsCount = totalViewsCount + self.otherViewsCount = otherViewsCount + } +} + +extension StatsTopPostsTimeIntervalData: StatsTimeIntervalData { + public static var pathComponent: String { + return "stats/top-posts" + } + + public init?(date: Date, period: StatsPeriodUnit, jsonDictionary: [String: AnyObject]) { + guard + let unwrappedDays = type(of: self).unwrapDaysDictionary(jsonDictionary: jsonDictionary), + let posts = unwrappedDays["postviews"] as? [[String: AnyObject]] + else { + return nil + } + + let totalViews = unwrappedDays["total_views"] as? Int ?? 0 + let otherViews = unwrappedDays["other_views"] as? Int ?? 0 + + self.periodEndDate = date + self.period = period + self.totalViewsCount = totalViews + self.otherViewsCount = otherViews + self.topPosts = posts.compactMap { StatsTopPost(topPostsJSONDictionary: $0) } + } +} + +private extension StatsTopPost { + + // the objects returned from this endpoint are _almost_ the same as the ones from `top-posts`, + // but with keys just subtly different enough that we need a custom init here. + init?(topPostsJSONDictionary jsonDictionary: [String: AnyObject]) { + guard + let url = jsonDictionary["href"] as? String, + let postID = jsonDictionary["id"] as? Int, + let title = jsonDictionary["title"] as? String, + let viewsCount = jsonDictionary["views"] as? Int, + let typeString = jsonDictionary["type"] as? String + else { + return nil + } + + self.title = title + self.date = jsonDictionary["date"] as? String + self.postID = postID + self.postURL = URL(string: url) + self.viewsCount = viewsCount + self.kind = type(of: self).kind(from: typeString) + } + +} diff --git a/Modules/Sources/WordPressKit/StatsTopReferrersTimeIntervalData.swift b/Modules/Sources/WordPressKit/StatsTopReferrersTimeIntervalData.swift new file mode 100644 index 000000000000..03c1bb98959a --- /dev/null +++ b/Modules/Sources/WordPressKit/StatsTopReferrersTimeIntervalData.swift @@ -0,0 +1,111 @@ +import Foundation +public struct StatsTopReferrersTimeIntervalData { + public let period: StatsPeriodUnit + public let periodEndDate: Date + + public let totalReferrerViewsCount: Int + public let otherReferrerViewsCount: Int + + public var referrers: [StatsReferrer] + + public init(period: StatsPeriodUnit, + periodEndDate: Date, + referrers: [StatsReferrer], + totalReferrerViewsCount: Int, + otherReferrerViewsCount: Int) { + self.period = period + self.periodEndDate = periodEndDate + self.referrers = referrers + self.totalReferrerViewsCount = totalReferrerViewsCount + self.otherReferrerViewsCount = otherReferrerViewsCount + } +} + +public struct StatsReferrer { + public let title: String + public let viewsCount: Int + public let url: URL? + public let iconURL: URL? + + public var children: [StatsReferrer] + public var isSpam = false + + public init(title: String, + viewsCount: Int, + url: URL?, + iconURL: URL?, + children: [StatsReferrer]) { + self.title = title + self.viewsCount = viewsCount + self.url = url + self.iconURL = iconURL + self.children = children + } +} + +extension StatsTopReferrersTimeIntervalData: StatsTimeIntervalData { + public static var pathComponent: String { + return "stats/referrers" + } + + public init?(date: Date, period: StatsPeriodUnit, jsonDictionary: [String: AnyObject]) { + guard + let unwrappedDays = type(of: self).unwrapDaysDictionary(jsonDictionary: jsonDictionary), + let referrers = unwrappedDays["groups"] as? [[String: AnyObject]] + else { + return nil + } + + let totalClicks = unwrappedDays["total_views"] as? Int ?? 0 + let otherClicks = unwrappedDays["other_views"] as? Int ?? 0 + + self.period = period + self.periodEndDate = date + self.totalReferrerViewsCount = totalClicks + self.otherReferrerViewsCount = otherClicks + self.referrers = referrers.compactMap { StatsReferrer(jsonDictionary: $0) } + } +} + +extension StatsReferrer { + init?(jsonDictionary: [String: AnyObject]) { + guard + let title = jsonDictionary["name"] as? String + else { + return nil + } + + // The shape of API reply here is _almost_ a perfectly fractal tree structure. + // However, sometimes the keys for children/parents representing the same values change, hence this + // rether ugly hack. + let viewsCount: Int + + if let views = jsonDictionary["total"] as? Int { + viewsCount = views + } else if let views = jsonDictionary["views"] as? Int { + viewsCount = views + } else { + // If neither key is present, this is a malformed response. + return nil + } + + let children: [StatsReferrer] + + if let childrenJSON = jsonDictionary["results"] as? [[String: AnyObject]] { + children = childrenJSON.compactMap { StatsReferrer(jsonDictionary: $0) } + } else if let childrenJSON = jsonDictionary["children"] as? [[String: AnyObject]] { + children = childrenJSON.compactMap { StatsReferrer(jsonDictionary: $0) } + } else { + children = [] + } + + let icon = jsonDictionary["icon"] as? String + let urlString = jsonDictionary["url"] as? String + + self.title = title + self.viewsCount = viewsCount + self.url = urlString.flatMap { URL(string: $0) } + self.iconURL = icon.flatMap { URL(string: $0) } + self.children = children + } +} diff --git a/Modules/Sources/WordPressKit/StatsTopVideosTimeIntervalData.swift b/Modules/Sources/WordPressKit/StatsTopVideosTimeIntervalData.swift new file mode 100644 index 000000000000..0690474bc261 --- /dev/null +++ b/Modules/Sources/WordPressKit/StatsTopVideosTimeIntervalData.swift @@ -0,0 +1,80 @@ +import Foundation +public struct StatsTopVideosTimeIntervalData { + public let period: StatsPeriodUnit + public let periodEndDate: Date + + public let totalPlaysCount: Int + public let otherPlayCount: Int + public let videos: [StatsVideo] + + public init(period: StatsPeriodUnit, + periodEndDate: Date, + videos: [StatsVideo], + totalPlaysCount: Int, + otherPlayCount: Int) { + self.period = period + self.periodEndDate = periodEndDate + self.videos = videos + self.totalPlaysCount = totalPlaysCount + self.otherPlayCount = otherPlayCount + } +} + +public struct StatsVideo { + public let postID: Int + public let title: String + public let playsCount: Int + public let videoURL: URL? + + public init(postID: Int, + title: String, + playsCount: Int, + videoURL: URL?) { + self.postID = postID + self.title = title + self.playsCount = playsCount + self.videoURL = videoURL + } +} + +extension StatsTopVideosTimeIntervalData: StatsTimeIntervalData { + + public static var pathComponent: String { + return "stats/video-plays" + } + + public init?(date: Date, period: StatsPeriodUnit, jsonDictionary: [String: AnyObject]) { + guard + let unwrappedDays = type(of: self).unwrapDaysDictionary(jsonDictionary: jsonDictionary), + let totalPlayCount = unwrappedDays["total_plays"] as? Int, + let otherPlays = unwrappedDays["other_plays"] as? Int, + let videos = unwrappedDays["plays"] as? [[String: AnyObject]] + else { + return nil + } + + self.period = period + self.periodEndDate = date + self.totalPlaysCount = totalPlayCount + self.otherPlayCount = otherPlays + self.videos = videos.compactMap { StatsVideo(jsonDictionary: $0) } + } +} + +extension StatsVideo { + init?(jsonDictionary: [String: AnyObject]) { + guard + let postID = jsonDictionary["post_id"] as? Int, + let title = jsonDictionary["title"] as? String, + let playsCount = jsonDictionary["plays"] as? Int, + let url = jsonDictionary["url"] as? String + else { + return nil + } + + self.postID = postID + self.title = title + self.playsCount = playsCount + self.videoURL = URL(string: url) + } +} diff --git a/Modules/Sources/WordPressKit/StatsTotalsSummaryData.swift b/Modules/Sources/WordPressKit/StatsTotalsSummaryData.swift new file mode 100644 index 000000000000..5276952051f7 --- /dev/null +++ b/Modules/Sources/WordPressKit/StatsTotalsSummaryData.swift @@ -0,0 +1,40 @@ +import Foundation +public struct StatsTotalsSummaryData { + public let period: StatsPeriodUnit + public let periodEndDate: Date + public let viewsCount: Int + public let visitorsCount: Int + public let likesCount: Int + public let commentsCount: Int + + public init( + period: StatsPeriodUnit, + periodEndDate: Date, + viewsCount: Int, + visitorsCount: Int, + likesCount: Int, + commentsCount: Int + ) { + self.period = period + self.periodEndDate = periodEndDate + self.viewsCount = viewsCount + self.visitorsCount = visitorsCount + self.likesCount = likesCount + self.commentsCount = commentsCount + } +} + +extension StatsTotalsSummaryData: StatsTimeIntervalData { + public static var pathComponent: String { + return "stats/summary" + } + + public init?(date: Date, period: StatsPeriodUnit, jsonDictionary: [String: AnyObject]) { + self.period = period + self.periodEndDate = date + self.visitorsCount = jsonDictionary["visitors"] as? Int ?? 0 + self.viewsCount = jsonDictionary["views"] as? Int ?? 0 + self.likesCount = jsonDictionary["likes"] as? Int ?? 0 + self.commentsCount = jsonDictionary["comments"] as? Int ?? 0 + } +} diff --git a/Modules/Sources/WordPressKit/String+Helpers.swift b/Modules/Sources/WordPressKit/String+Helpers.swift new file mode 100644 index 000000000000..cc73aa2f4a64 --- /dev/null +++ b/Modules/Sources/WordPressKit/String+Helpers.swift @@ -0,0 +1,167 @@ +import Foundation + +extension String { + func stringByDecodingXMLCharacters() -> String { + return NSString.wpkit_decodeXMLCharacters(in: self) + } + + func stringByEncodingXMLCharacters() -> String { + return NSString.wpkit_encodeXMLCharacters(in: self) + } + + func trim() -> String { + return trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) + } + + /// Returns `self` if not empty, or `nil` otherwise + /// + func nonEmptyString() -> String? { + return isEmpty ? nil : self + } + + /// Returns a string without the character at the specified index. + /// This is a non-mutating version of `String.remove(at:)`. + func removing(at index: Index) -> String { + var copy = self + copy.remove(at: index) + return copy + } + + /// Returns a count of valid text characters. + /// - Note : This implementation is influenced by `-wordCount` in `NSString+Helpers`. + var characterCount: Int { + var charCount = 0 + + if isEmpty == false { + let textRange = startIndex.. String { + var copy = self + copy.removePrefix(prefix) + return copy + } + + /// Removes the prefix from the string that matches the given pattern, if any. + /// + /// Calling this method might invalidate any existing indices for use with this string. + /// + /// - Parameters: + /// - pattern: The regular expression pattern to search for. Avoid using `^`. + /// - options: The options applied to the regular expression during matching. + /// + /// - Throws: an error if it the pattern is not a valid regular expression. + /// + mutating func removePrefix(pattern: String, options: NSRegularExpression.Options = []) throws { + let regexp = try NSRegularExpression(pattern: "^\(pattern)", options: options) + let fullRange = NSRange(location: 0, length: (self as NSString).length) + if let match = regexp.firstMatch(in: self, options: [], range: fullRange) { + let matchRange = match.range + self = (self as NSString).replacingCharacters(in: matchRange, with: "") + } + } + + /// Returns a string without the prefix that matches the given pattern, if it exists. + /// + /// - Parameters: + /// - pattern: The regular expression pattern to search for. Avoid using `^`. + /// - options: The options applied to the regular expression during matching. + /// + /// - Throws: an error if it the pattern is not a valid regular expression. + /// + func removingPrefix(pattern: String, options: NSRegularExpression.Options = []) throws -> String { + var copy = self + try copy.removePrefix(pattern: pattern, options: options) + return copy + } +} + +// MARK: - Suffix removal + +extension String { + /// Removes the given suffix from the string, if exists. + /// + /// Calling this method might invalidate any existing indices for use with this string. + /// + /// - Parameters: + /// - suffix: A possible suffix to remove from this string. + /// + mutating func removeSuffix(_ suffix: String) { + if let suffixRange = range(of: suffix, options: [.backwards]), suffixRange.upperBound == endIndex { + removeSubrange(suffixRange) + } + } + + /// Returns a string with the given suffix removed, if it exists. + /// + /// - Parameters: + /// - suffix: A possible suffix to remove from this string. + /// + func removingSuffix(_ suffix: String) -> String { + var copy = self + copy.removeSuffix(suffix) + return copy + } + + /// Removes the suffix from the string that matches the given pattern, if any. + /// + /// Calling this method might invalidate any existing indices for use with this string. + /// + /// - Parameters: + /// - pattern: The regular expression pattern to search for. Avoid using `$`. + /// - options: The options applied to the regular expression during matching. + /// + /// - Throws: an error if it the pattern is not a valid regular expression. + /// + mutating func removeSuffix(pattern: String, options: NSRegularExpression.Options = []) throws { + let regexp = try NSRegularExpression(pattern: "\(pattern)$", options: options) + let fullRange = NSRange(location: 0, length: (self as NSString).length) + if let match = regexp.firstMatch(in: self, options: [], range: fullRange) { + let matchRange = match.range + self = (self as NSString).replacingCharacters(in: matchRange, with: "") + } + } + + /// Returns a string without the suffix that matches the given pattern, if it exists. + /// + /// - Parameters: + /// - pattern: The regular expression pattern to search for. Avoid using `$`. + /// - options: The options applied to the regular expression during matching. + /// + /// - Throws: an error if it the pattern is not a valid regular expression. + /// + func removingSuffix(pattern: String, options: NSRegularExpression.Options = []) throws -> String { + var copy = self + try copy.removeSuffix(pattern: pattern, options: options) + return copy + } +} diff --git a/Modules/Sources/WordPressKit/StringCodingKey.swift b/Modules/Sources/WordPressKit/StringCodingKey.swift new file mode 100644 index 000000000000..9f4c2bb11c14 --- /dev/null +++ b/Modules/Sources/WordPressKit/StringCodingKey.swift @@ -0,0 +1,27 @@ +import Foundation + +struct StringCodingKey: CodingKey, ExpressibleByStringLiteral { + private let string: String + private var int: Int? + + var stringValue: String { return string } + + init(string: String) { + self.string = string + } + + init?(stringValue: String) { + self.string = stringValue + } + + var intValue: Int? { return int } + + init?(intValue: Int) { + self.string = String(describing: intValue) + self.int = intValue + } + + init(stringLiteral value: String) { + self.string = value + } +} diff --git a/Modules/Sources/WordPressKit/StringEncoding+IANA.swift b/Modules/Sources/WordPressKit/StringEncoding+IANA.swift new file mode 100644 index 000000000000..c4d92d7efa69 --- /dev/null +++ b/Modules/Sources/WordPressKit/StringEncoding+IANA.swift @@ -0,0 +1,44 @@ +import Foundation + +extension String.Encoding { + /// See: https://www.iana.org/assignments/character-sets/character-sets.xhtml + init?(ianaCharsetName: String) { + let encoding: CFStringEncoding = CFStringConvertIANACharSetNameToEncoding(ianaCharsetName as CFString) + guard encoding != kCFStringEncodingInvalidId, + let builtInEncoding = CFStringBuiltInEncodings(rawValue: encoding) + else { + return nil + } + + switch builtInEncoding { + case .macRoman: + self = .macOSRoman + case .windowsLatin1: + self = .windowsCP1252 + case .isoLatin1: + self = .isoLatin1 + case .nextStepLatin: + self = .nextstep + case .ASCII: + self = .ascii + case .unicode: + self = .unicode + case .UTF8: + self = .utf8 + case .nonLossyASCII: + self = .nonLossyASCII + case .UTF16BE: + self = .utf16BigEndian + case .UTF16LE: + self = .utf16LittleEndian + case .UTF32: + self = .utf32 + case .UTF32BE: + self = .utf32BigEndian + case .UTF32LE: + self = .utf32LittleEndian + @unknown default: + return nil + } + } +} diff --git a/Modules/Sources/WordPressKit/SubscribersServiceRemote.swift b/Modules/Sources/WordPressKit/SubscribersServiceRemote.swift new file mode 100644 index 000000000000..6771106fc961 --- /dev/null +++ b/Modules/Sources/WordPressKit/SubscribersServiceRemote.swift @@ -0,0 +1,287 @@ +import Foundation +import WordPressKitObjC + +public class SubscribersServiceRemote: ServiceRemoteWordPressComREST { + + // MARK: GET Subscribers (Paginated List) + + public struct GetSubscribersParameters: Hashable { + public var sortField: SortField? + public var sortOrder: SortOrder? + public var subscriptionTypeFilter: FilterSubscriptionType? + public var paymentTypeFilter: FilterPaymentType? + + @frozen public enum SortField: String, CaseIterable { + case dateSubscribed = "date_subscribed" + case email = "email" + case name = "name" + case plan = "plan" + case subscriptionStatus = "subscription_status" + } + + @frozen public enum SortOrder: String, CaseIterable { + case ascending = "asc" + case descending = "dsc" + } + + @frozen public enum FilterSubscriptionType: String, CaseIterable { + case email = "email_subscriber" + case reader = "reader_subscriber" + case unconfirmed = "unconfirmed_subscriber" + case blocked = "blocked_subscriber" + } + + @frozen public enum FilterPaymentType: String, CaseIterable { + case free + case paid + } + + public var filters: [String] { + [subscriptionTypeFilter?.rawValue, paymentTypeFilter?.rawValue].compactMap { $0 } + } + + public init(sortField: SortField? = nil, sortOrder: SortOrder? = nil, subscriptionTypeFilter: FilterSubscriptionType? = nil, paymentTypeFilter: FilterPaymentType? = nil) { + self.sortField = sortField + self.sortOrder = sortOrder + self.subscriptionTypeFilter = subscriptionTypeFilter + self.paymentTypeFilter = paymentTypeFilter + } + } + + public struct GetSubscribersResponse: Decodable { + public var total: Int + public var pages: Int + public var page: Int + public var subscribers: [Subscriber] + + public struct Subscriber: Decodable, SubsciberBasicInfoResponse { + public let subscriberID: Int + public let dotComUserID: Int + public let displayName: String? + public let avatar: String? + public let emailAddress: String? + public let dateSubscribed: Date + public let isEmailSubscriptionEnabled: Bool + public let subscriptionStatus: String? + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: StringCodingKey.self) + subscriberID = try container.decode(Int.self, forKey: "subscription_id") + dotComUserID = try container.decode(Int.self, forKey: "user_id") + displayName = try? container.decodeIfPresent(String.self, forKey: "display_name") + avatar = try? container.decodeIfPresent(String.self, forKey: "avatar") + emailAddress = try? container.decodeIfPresent(String.self, forKey: "email_address") + dateSubscribed = try container.decode(Date.self, forKey: "date_subscribed") + isEmailSubscriptionEnabled = try container.decode(Bool.self, forKey: "is_email_subscriber") + subscriptionStatus = try? container.decodeIfPresent(String.self, forKey: "subscription_status") + } + } + } + + /// Gets the list of the site subscribers, including WordPress.com users and + /// email subscribers. + public func getSubscribers( + siteID: Int, + page: Int? = nil, + perPage: Int? = 25, + parameters: GetSubscribersParameters = .init(), + search: String? = nil + ) async throws -> GetSubscribersResponse { + let url = self.path(forEndpoint: "sites/\(siteID)/subscribers", withVersion: ._2_0) + var query: [String: Any] = [:] + if let page { + query["page"] = page + } + if let perPage { + query["per_page"] = perPage + } + if let sortField = parameters.sortField { + query["sort"] = sortField.rawValue + } + if let sortOrder = parameters.sortOrder { + query["sort_order"] = sortOrder.rawValue + } + if !parameters.filters.isEmpty { + query["filters"] = parameters.filters + } + if let search, !search.isEmpty { + query["search"] = search + } + + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = JSONDecoder.DateDecodingStrategy.supportMultipleDateFormats + + return try await wordPressComRestApi.perform( + .get, + URLString: url, + parameters: query, + jsonDecoder: decoder, + type: GetSubscribersResponse.self + ).get().body + } + + // MARK: GET Subscriber (Individual Details) + + public protocol SubsciberBasicInfoResponse { + var dotComUserID: Int { get } + var subscriberID: Int { get } + var displayName: String? { get } + var emailAddress: String? { get } + var avatar: String? { get } + var dateSubscribed: Date { get } + } + + public final class GetSubscriberDetailsResponse: Decodable, SubsciberBasicInfoResponse { + public let subscriberID: Int + public let dotComUserID: Int + public let displayName: String? + public let avatar: String? + public let emailAddress: String? + public let siteURL: String? + public let dateSubscribed: Date + public let isEmailSubscriptionEnabled: Bool + public let subscriptionStatus: String? + public let country: Country? + public let plans: [Plan]? + + public struct Country: Decodable { + public var code: String? + public var name: String? + } + + public struct Plan: Decodable { + public let isGift: Bool + public let giftId: Int? + public let paidSubscriptionId: String? + public let status: String + public let title: String + public let currency: String? + public let renewInterval: String? + public let inactiveRenewInterval: String? + public let renewalPrice: Decimal + public let startDate: Date + public let endDate: Date + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: StringCodingKey.self) + isGift = try container.decode(Bool.self, forKey: "is_gift") + giftId = try container.decodeIfPresent(Int.self, forKey: "gift_id") + paidSubscriptionId = try container.decodeIfPresent(String.self, forKey: "paid_subscription_id") + status = try container.decode(String.self, forKey: "status") + title = try container.decode(String.self, forKey: "title") + currency = try container.decodeIfPresent(String.self, forKey: "currency") + renewInterval = try? container.decodeIfPresent(String.self, forKey: "renew_interval") + inactiveRenewInterval = try? container.decodeIfPresent(String.self, forKey: "inactive_renew_interval") + renewalPrice = try container.decode(Decimal.self, forKey: "renewal_price") + startDate = try container.decode(Date.self, forKey: "start_date") + endDate = try container.decode(Date.self, forKey: "end_date") + } + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: StringCodingKey.self) + subscriberID = try container.decode(Int.self, forKey: "subscription_id") + dotComUserID = try container.decode(Int.self, forKey: "user_id") + displayName = try? container.decodeIfPresent(String.self, forKey: "display_name") + avatar = try? container.decodeIfPresent(String.self, forKey: "avatar") + emailAddress = try? container.decodeIfPresent(String.self, forKey: "email_address") + siteURL = try? container.decodeIfPresent(String.self, forKey: "url") + dateSubscribed = try container.decode(Date.self, forKey: "date_subscribed") + isEmailSubscriptionEnabled = try container.decode(Bool.self, forKey: "is_email_subscriber") + subscriptionStatus = try? container.decodeIfPresent(String.self, forKey: "subscription_status") + country = try? container.decodeIfPresent(Country.self, forKey: "country") + plans = try container.decodeIfPresent([Plan].self, forKey: "plans") + } + } + + /// Gets stats for the given subscriber. + /// + /// Example: https://public-api.wordpress.com/wpcom/v2/sites/239619264/subscribers/individual?subscription_id=907116368 + public func getSubsciberDetails( + siteID: Int, + subscriberID: Int, + type: String = "email" + ) async throws -> GetSubscriberDetailsResponse { + let url = self.path(forEndpoint: "sites/\(siteID)/subscribers/individual", withVersion: ._2_0) + let query: [String: Any] = [ + "subscription_id": subscriberID, + "type": type + ] + + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = JSONDecoder.DateDecodingStrategy.supportMultipleDateFormats + + return try await wordPressComRestApi.perform( + .get, + URLString: url, + parameters: query, + jsonDecoder: decoder, + type: GetSubscriberDetailsResponse.self + ).get().body + } + + public struct GetSubscriberStatsResponse: Decodable { + public var emailsSent: Int + public var uniqueOpens: Int + public var uniqueClicks: Int + } + + /// Gets stats for the given subscriber. + /// + /// Example: https://public-api.wordpress.com/wpcom/v2/sites/239619264/individual-subscriber-stats?subscription_id=907116368 + public func getSubsciberStats( + siteID: Int, + subscriberID: Int + ) async throws -> GetSubscriberStatsResponse { + let url = self.path(forEndpoint: "sites/\(siteID)/individual-subscriber-stats", withVersion: ._2_0) + let query: [String: Any] = [ + "subscription_id": subscriberID + ] + return try await wordPressComRestApi.perform( + .get, + URLString: url, + parameters: query, + jsonDecoder: JSONDecoder.apiDecoder, + type: GetSubscriberStatsResponse.self + ).get().body + } + + // MARK: POST Import Subscribers + + /// Example: URL: https://public-api.wordpress.com/wpcom/v2/sites/216878809/subscribers/import?_envelope=1 + @discardableResult + public func importSubscribers( + siteID: Int, + emails: [String] + ) async throws -> ImportSubscribersResponse { + let url = self.path(forEndpoint: "sites/\(siteID)/subscribers/import", withVersion: ._2_0) + let parameters: [String: Any] = [ + "emails": emails, + "parse_only": false + ] + return try await wordPressComRestApi.perform( + .post, + URLString: url, + parameters: parameters, + type: ImportSubscribersResponse.self + ).get().body + } + + public struct ImportSubscribersResponse: Decodable { + public let uploadID: Int + + enum CodingKeys: String, CodingKey { + case uploadID = "upload_id" + } + } +} + +extension SubscribersServiceRemote.SubsciberBasicInfoResponse { + public var avatarURL: URL? { + avatar.flatMap(URL.init) + } + + public var isDotComUser: Bool { + dotComUserID > 0 + } +} diff --git a/Modules/Sources/WordPressKit/TimeZoneServiceRemote.swift b/Modules/Sources/WordPressKit/TimeZoneServiceRemote.swift new file mode 100644 index 000000000000..726851949153 --- /dev/null +++ b/Modules/Sources/WordPressKit/TimeZoneServiceRemote.swift @@ -0,0 +1,60 @@ +import Foundation +import WordPressKitObjC + +public class TimeZoneServiceRemote: ServiceRemoteWordPressComREST { + public enum ResponseError: Error { + case decodingFailed + } + + public func getTimezones(success: @escaping (([TimeZoneGroup]) -> Void), failure: @escaping ((Error) -> Void)) { + let endpoint = "timezones" + let path = self.path(forEndpoint: endpoint, withVersion: ._2_0) + wordPressComRESTAPI.get(path, parameters: nil, success: { (response, _) in + do { + let groups = try self.timezoneGroupsFromResponse(response) + success(groups) + } catch { + failure(error) + } + }) { (error, _) in + failure(error) + } + } +} + +private extension TimeZoneServiceRemote { + func timezoneGroupsFromResponse(_ response: Any) throws -> [TimeZoneGroup] { + guard let response = response as? [String: Any], + let timeZonesByContinent = response["timezones_by_continent"] as? [String: [[String: String]]], + let manualUTCOffsets = response["manual_utc_offsets"] as? [[String: String]] else { + throw ResponseError.decodingFailed + } + let continentGroups: [TimeZoneGroup] = try timeZonesByContinent.map({ + let (groupName, rawZones) = $0 + let zones = try rawZones.map({ try parseNamedTimezone(response: $0) }) + return TimeZoneGroup(name: groupName, timezones: zones) + }).sorted(by: { return $0.name < $1.name }) + + let utcOffsets: [WPTimeZone] = try manualUTCOffsets.map({ try parseOffsetTimezone(response: $0) }) + let utcOffsetsGroup = TimeZoneGroup( + name: NSLocalizedString("Manual Offsets", comment: "Section name for manual offsets in time zone selector"), + timezones: utcOffsets) + return continentGroups + [utcOffsetsGroup] + } + + func parseNamedTimezone(response: [String: String]) throws -> WPTimeZone { + guard let label = response["label"], + let value = response["value"] else { + throw ResponseError.decodingFailed + } + return NamedTimeZone(label: label, value: value) + } + + func parseOffsetTimezone(response: [String: String]) throws -> WPTimeZone { + guard let value = response["value"], + let zone = OffsetTimeZone.fromValue(value) else { + throw ResponseError.decodingFailed + } + return zone + } +} diff --git a/Modules/Sources/WordPressKit/TransactionsServiceRemote.swift b/Modules/Sources/WordPressKit/TransactionsServiceRemote.swift new file mode 100644 index 000000000000..d471c5354170 --- /dev/null +++ b/Modules/Sources/WordPressKit/TransactionsServiceRemote.swift @@ -0,0 +1,210 @@ +import Foundation +import WordPressKitObjC + +@objc public class TransactionsServiceRemote: ServiceRemoteWordPressComREST { + + public enum ResponseError: Error { + case decodingFailure + } + + private enum Constants { + static let freeDomainPaymentMethod = "WPCOM_Billing_WPCOM" + } + + @objc public func getSupportedCountries(success: @escaping ([WPCountry]) -> Void, + failure: @escaping (Error) -> Void) { + let endPoint = "me/transactions/supported-countries/" + let servicePath = path(forEndpoint: endPoint, withVersion: ._1_1) + + wordPressComRESTAPI.get(servicePath, + parameters: nil, + success: { + response, _ in + do { + guard let json = response as? [AnyObject] else { + throw ResponseError.decodingFailure + } + let data = try JSONSerialization.data(withJSONObject: json, options: .prettyPrinted) + let decodedResult = try JSONDecoder.apiDecoder.decode([WPCountry].self, from: data) + success(decodedResult) + } catch { + WPKitLogError("Error parsing Supported Countries (\(error)): \(response)") + failure(error) + } + }, failure: { error, _ in + failure(error) + }) + } + + /// Creates a shopping cart with products + /// - Parameters: + /// - siteID: id of the current site + /// - products: an array of products to be added to the newly created cart + /// - temporary: true if the card is temporary, false otherwise + public func createShoppingCart(siteID: Int?, + products: [TransactionsServiceProduct], + temporary: Bool, + success: @escaping (CartResponse) -> Void, + failure: @escaping (Error) -> Void) { + let siteIDString = siteID != nil ? "\(siteID ?? 0)" : "no-site" + let endPoint = "me/shopping-cart/\(siteIDString)" + let urlPath = path(forEndpoint: endPoint, withVersion: ._1_1) + + var productsDictionary: [[String: AnyObject]] = [] + + for product in products { + switch product { + case .domain(let domainSuggestion, let privacyProtectionEnabled): + productsDictionary.append(["product_id": domainSuggestion.productID as AnyObject, + "meta": domainSuggestion.domainName as AnyObject, + "extra": ["privacy": privacyProtectionEnabled] as AnyObject]) + + case .plan(let productId): + productsDictionary.append(["product_id": productId as AnyObject]) + case .other(let productDict): + productsDictionary.append(productDict) + } + } + + let parameters: [String: AnyObject] = ["temporary": (temporary ? "true" : "false") as AnyObject, + "products": productsDictionary as AnyObject] + + wordPressComRESTAPI.post(urlPath, + parameters: parameters, + success: { (response, _) in + + guard let jsonResponse = response as? [String: AnyObject], + let cart = CartResponse(jsonDictionary: jsonResponse), + !cart.products.isEmpty else { + + failure(TransactionsServiceRemote.ResponseError.decodingFailure) + return + } + + success(cart) + }) { (error, _) in + failure(error) + } + } + + // MARK: - Domains + + public func redeemCartUsingCredits(cart: CartResponse, + domainContactInformation: [String: String], + success: @escaping () -> Void, + failure: @escaping (Error) -> Void) { + + let endPoint = "me/transactions" + + let urlPath = path(forEndpoint: endPoint, withVersion: ._1_1) + + let paymentDict = ["payment_method": Constants.freeDomainPaymentMethod] + + let parameters: [String: AnyObject] = ["domain_details": domainContactInformation as AnyObject, + "cart": cart.jsonRepresentation() as AnyObject, + "payment": paymentDict as AnyObject] + + wordPressComRESTAPI.post(urlPath, parameters: parameters, success: { (_, _) in + success() + }) { (error, _) in + failure(error) + } + } + + /// Creates a temporary shopping cart for a domain purchase + @available(*, deprecated, message: "Use createShoppingCart(_:) and pass an array of specific products instead") + public func createTemporaryDomainShoppingCart(siteID: Int, + domainSuggestion: DomainSuggestion, + privacyProtectionEnabled: Bool, + success: @escaping (CartResponse) -> Void, + failure: @escaping (Error) -> Void) { + createShoppingCart(siteID: siteID, + products: [.domain(domainSuggestion, privacyProtectionEnabled)], + temporary: true, + success: success, + failure: failure) + } + + /// Creates a persistent shopping cart for a domain purchase + @available(*, deprecated, message: "Use createShoppingCart(_:) and pass an array of specific products instead") + public func createPersistentDomainShoppingCart(siteID: Int, + domainSuggestion: DomainSuggestion, + privacyProtectionEnabled: Bool, + success: @escaping (CartResponse) -> Void, + failure: @escaping (Error) -> Void) { + createShoppingCart(siteID: siteID, + products: [.domain(domainSuggestion, privacyProtectionEnabled)], + temporary: false, + success: success, + failure: failure) + } +} + +public enum TransactionsServiceProduct { + public typealias ProductId = Int + public typealias PrivacyProtection = Bool + + case domain(DomainSuggestion, PrivacyProtection) + case plan(ProductId) + case other([String: AnyObject]) +} + +public struct CartResponse { + let blogID: Int + let cartKey: Any // cart key can be either Int or String + let products: [Product] + + init?(jsonDictionary: [String: AnyObject]) { + guard + let cartKey = jsonDictionary["cart_key"], + let blogID = jsonDictionary["blog_id"] as? Int, + let products = jsonDictionary["products"] as? [[String: AnyObject]] + else { + return nil + } + + let mappedProducts = products.compactMap { (product) -> Product? in + guard + let productID = product["product_id"] as? Int else { + return nil + } + let meta = product["meta"] as? String + let extra = product["extra"] as? [String: AnyObject] + + return Product(productID: productID, meta: meta, extra: extra) + } + + self.blogID = blogID + self.cartKey = cartKey + self.products = mappedProducts + } + + fileprivate func jsonRepresentation() -> [String: AnyObject] { + return ["blog_id": blogID as AnyObject, + "cart_key": cartKey as AnyObject, + "products": products.map { $0.jsonRepresentation() } as AnyObject] + + } +} + +public struct Product { + let productID: Int + let meta: String? + let extra: [String: AnyObject]? + + fileprivate func jsonRepresentation() -> [String: AnyObject] { + var returnDict: [String: AnyObject] = [:] + + returnDict["product_id"] = productID as AnyObject + + if let meta { + returnDict["meta"] = meta as AnyObject + } + + if let extra { + returnDict["extra"] = extra as AnyObject + } + + return returnDict + } +} diff --git a/Modules/Sources/WordPressKit/UIDevice+Extensions.swift b/Modules/Sources/WordPressKit/UIDevice+Extensions.swift new file mode 100644 index 000000000000..10d959121f69 --- /dev/null +++ b/Modules/Sources/WordPressKit/UIDevice+Extensions.swift @@ -0,0 +1,12 @@ +import Foundation +import UIKit + +extension UIDevice { + var platform: String { + var size = 0 + sysctlbyname("hw.machine", nil, &size, nil, 0) + var machine = [CChar](repeating: 0, count: size) + sysctlbyname("hw.machine", &machine, &size, nil, 0) + return String(cString: machine) + } +} diff --git a/Modules/Sources/WordPressKit/UsersServiceRemoteXMLRPC.swift b/Modules/Sources/WordPressKit/UsersServiceRemoteXMLRPC.swift new file mode 100644 index 000000000000..6589b761c76a --- /dev/null +++ b/Modules/Sources/WordPressKit/UsersServiceRemoteXMLRPC.swift @@ -0,0 +1,30 @@ +import Foundation + +public enum UsersServiceRemoteError: Int, Error { + case UnexpectedResponseData +} + +/// UsersServiceRemoteXMLRPC handles Users related XML-RPC calls. +/// https://codex.wordpress.org/XML-RPC_WordPress_API/Users +/// +public class UsersServiceRemoteXMLRPC: ServiceRemoteWordPressXMLRPC { + + /// Fetch the blog user's profile. + /// + public func fetchProfile(_ success: @escaping ((RemoteProfile) -> Void), failure: @escaping ((NSError?) -> Void)) { + let params = defaultXMLRPCArguments() as [AnyObject] + api.callMethod("wp.getProfile", parameters: params, success: { (responseObj, _) in + guard let dict = responseObj as? NSDictionary else { + assertionFailure("A dictionary was expected but the API returned something different.") + failure(UsersServiceRemoteError.UnexpectedResponseData as NSError) + return + } + let profile = RemoteProfile(dictionary: dict) + success(profile) + + }, failure: { (error, _) in + failure(error as NSError) + }) + } + +} diff --git a/Modules/Sources/WordPressKit/WPCountry.swift b/Modules/Sources/WordPressKit/WPCountry.swift new file mode 100644 index 000000000000..5a0141941a01 --- /dev/null +++ b/Modules/Sources/WordPressKit/WPCountry.swift @@ -0,0 +1,6 @@ +import Foundation + +@objc public class WPCountry: NSObject, Codable { + public var code: String? + public var name: String? +} diff --git a/Modules/Sources/WordPressKit/WPKitLogging.swift b/Modules/Sources/WordPressKit/WPKitLogging.swift new file mode 100644 index 000000000000..74b484e803ab --- /dev/null +++ b/Modules/Sources/WordPressKit/WPKitLogging.swift @@ -0,0 +1,20 @@ +import Foundation +func WPKitLogError(_ format: String, _ arguments: CVarArg...) { + withVaList(arguments) { WPKitLogvError(format, $0) } +} + +func WPKitLogWarning(_ format: String, _ arguments: CVarArg...) { + withVaList(arguments) { WPKitLogvWarning(format, $0) } +} + +func WPKitLogInfo(_ format: String, _ arguments: CVarArg...) { + withVaList(arguments) { WPKitLogvInfo(format, $0) } +} + +func WPKitLogDebug(_ format: String, _ arguments: CVarArg...) { + withVaList(arguments) { WPKitLogvDebug(format, $0) } +} + +func WPKitLogVerbose(_ format: String, _ arguments: CVarArg...) { + withVaList(arguments) { WPKitLogvVerbose(format, $0) } +} diff --git a/Modules/Sources/WordPressKit/WPState.swift b/Modules/Sources/WordPressKit/WPState.swift new file mode 100644 index 000000000000..630ad40d9d51 --- /dev/null +++ b/Modules/Sources/WordPressKit/WPState.swift @@ -0,0 +1,6 @@ +import Foundation + +@objc public class WPState: NSObject, Codable { + public var code: String? + public var name: String? +} diff --git a/Modules/Sources/WordPressKit/WPTimeZone.swift b/Modules/Sources/WordPressKit/WPTimeZone.swift new file mode 100644 index 000000000000..9654d2ad406e --- /dev/null +++ b/Modules/Sources/WordPressKit/WPTimeZone.swift @@ -0,0 +1,103 @@ +import Foundation + +/// A model to represent known WordPress.com timezones +/// +public protocol WPTimeZone { + var label: String { get } + var value: String { get } + + var gmtOffset: Float? { get } + var timezoneString: String? { get } +} + +extension WPTimeZone { + public var timezoneString: String? { + return value + } +} + +public struct TimeZoneGroup { + public let name: String + public let timezones: [WPTimeZone] + + public init(name: String, timezones: [WPTimeZone]) { + self.name = name + self.timezones = timezones + } +} + +public struct NamedTimeZone: WPTimeZone { + public let label: String + public let value: String + + public init(label: String, value: String) { + self.label = label + self.value = value + } + + public var gmtOffset: Float? { + return nil + } +} + +public struct OffsetTimeZone: WPTimeZone { + let offset: Float + + public init(offset: Float) { + self.offset = offset + } + + public var label: String { + if offset == 0 { + return "UTC" + } else if offset > 0 { + return "UTC+\(hourOffset)\(minuteOffsetString)" + } else { + return "UTC\(hourOffset)\(minuteOffsetString)" + } + } + + public var value: String { + let offsetString = String(format: "%g", offset) + if offset == 0 { + return "UTC" + } else if offset > 0 { + return "UTC+\(offsetString)" + } else { + return "UTC\(offsetString)" + } + } + + public var gmtOffset: Float? { + return offset + } + + static func fromValue(_ value: String) -> OffsetTimeZone? { + guard let offsetString = try? value.removingPrefix(pattern: "UTC") else { + return nil + } + let offset: Float? + if offsetString.isEmpty { + offset = 0 + } else { + offset = Float(offsetString) + } + return offset.map(OffsetTimeZone.init) + } + + private var hourOffset: Int { + return Int(offset.rounded(.towardZero)) + } + + private var minuteOffset: Int { + return Int(abs(offset.truncatingRemainder(dividingBy: 1) * 60)) + } + + private var minuteOffsetString: String { + if minuteOffset != 0 { + return ":\(minuteOffset)" + } else { + return "" + } + } +} diff --git a/Modules/Sources/WordPressKit/WebauthChallengeInfo.swift b/Modules/Sources/WordPressKit/WebauthChallengeInfo.swift new file mode 100644 index 000000000000..a07da3a489bf --- /dev/null +++ b/Modules/Sources/WordPressKit/WebauthChallengeInfo.swift @@ -0,0 +1,28 @@ +import Foundation + +/// Type that represents the Webauthn challenge info return by Wordpress.com +/// +@objc public class WebauthnChallengeInfo: NSObject { + /// Challenge to be signed. + /// + @objc public var challenge = "" + + /// The website this request is for + /// + @objc public var rpID = "" + + /// Nonce required by Wordpress.com to verify the signed challenge + /// + @objc public var twoStepNonce = "" + + /// Allowed credential IDs. + /// + @objc public var allowedCredentialIDs: [String] = [] + + init(challenge: String, rpID: String, twoStepNonce: String, allowedCredentialIDs: [String]) { + self.challenge = challenge + self.rpID = rpID + self.twoStepNonce = twoStepNonce + self.allowedCredentialIDs = allowedCredentialIDs + } +} diff --git a/Modules/Sources/WordPressKit/WordPressAPIError+NSErrorBridge.swift b/Modules/Sources/WordPressKit/WordPressAPIError+NSErrorBridge.swift new file mode 100644 index 000000000000..fb50eb7f1e87 --- /dev/null +++ b/Modules/Sources/WordPressKit/WordPressAPIError+NSErrorBridge.swift @@ -0,0 +1,113 @@ +import Foundation + +/// Custom `NSError` bridge implementation. +/// +/// The implementation ensures `NSError` instances that are casted from `WordPressAPIError.endpointError` +/// are the same as those instances that are casted directly from the underlying `EndpointError` instances. +/// +/// In theory, we should not need to implement this bridging, because we should never cast errors to `NSError` +/// instances in any error handling code. But since there are still Objective-C callers, providing this custom bridging +/// implementation comes in handy for those cases. See the `WordPressComRestApiEndpointError` extension below. +extension WordPressAPIError: CustomNSError { + + public static var errorDomain: String { + (EndpointError.self as? CustomNSError.Type)?.errorDomain + ?? String(describing: Self.self) + } + + public var errorCode: Int { + switch self { + case let .endpointError(endpointError): + return (endpointError as NSError).code + // Use negative values for other cases to reduce chances of collision with `EndpointError`. + case .requestEncodingFailure: + return -100000 + case .connection: + return -100001 + case .unacceptableStatusCode: + return -100002 + case .unparsableResponse: + return -100003 + case .unknown: + return -100004 + } + } + + public var errorUserInfo: [String: Any] { + switch self { + case let .endpointError(endpointError): + return (endpointError as NSError).userInfo + case .connection(let error): + return [NSUnderlyingErrorKey: error] + case .requestEncodingFailure, .unacceptableStatusCode, .unparsableResponse, + .unknown: + return [:] + } + } +} + +// MARK: - Bridge WordPressComRestApiEndpointError to NSError + +/// A custom NSError bridge implementation to ensure `NSError` instances converted from `WordPressComRestApiEndpointError` +/// are the same as the ones converted from their underlying error (the `code: WordPressComRestApiError` property in +/// `WordPressComRestApiEndpointError`). +/// +/// Along with `WordPressAPIError`'s conformance to `CustomNSError`, the three `NSError` instances below have the +/// same domain and code. +/// +/// ``` +/// let error: WordPressComRestApiError = // ... +/// let newError: WordPressComRestApiEndpointError = .init(code: error) +/// let apiError: WordPressAPIError = .endpointError(newError) +/// +/// // Following `NSError` instance have the same domain and code. +/// let errorNSError = error as NSError +/// let newErrorNSError = newError as NSError +/// let apiErrorNSError = apiError as NSError +/// ``` +/// +/// ## Why implementing this custom NSError brdige? +/// +/// `WordPressComRestApi` returns `NSError` instances to their callers. Since `WordPressComRestApi` is used in many +/// Objective-C file, we can't change the API to return an `Error` type that's Swift-only (i.e. `WordPressAPIError`). +/// If the day where there are no Objective-C callers finally comes, we definitely should stop returning `NSError` and +/// start using a concrete error type instead. But for now, we have to provide backwards compatiblity to those +/// Objective-C code while using `WordPressAPIError` internally in `WordPressComRestApi`. +/// +/// The `NSError` instances returned by `WordPressComRestApi` is one of the following: +/// - `WordPressComRestApiError` enum cases that are directly converted to `NSError` +/// - `NSError` instances that have domain and code from `WordPressComRestApiError`, with additional `userInfo` (error +/// code, message, etc). +/// - Error instances returned by Alamofire 4: `AFError`, or maybe other errors. +/// +/// Alamofire will be removed from this library, there is no point (also not possible) in providing backwards +/// compatiblity to `AFError`. That means, we need to make sure the `NSError` instances that are converted from +/// `WordPressAPIError` have the same error domain and code as the underlying `WordPressComRestApiError` enum. +/// And in cases where additional user info was provided, they need to be carried over to the `NSError` instances. +extension WordPressComRestApiEndpointError: CustomNSError { + + public static let errorDomain = WordPressComRestApiErrorDomain + + public var errorCode: Int { + code.rawValue + } + + public var errorUserInfo: [String: Any] { + var userInfo = additionalUserInfo ?? [:] + + if let code = apiErrorCode { + userInfo[WordPressComRestApi.ErrorKeyErrorCode] = code + } + if let message = apiErrorMessage { + userInfo[WordPressComRestApi.ErrorKeyErrorMessage] = message + userInfo[NSLocalizedDescriptionKey] = message + } + if let data = apiErrorData { + userInfo[WordPressComRestApi.ErrorKeyErrorData] = data + } + + return userInfo + + } + +} diff --git a/Modules/Sources/WordPressKit/WordPressAPIError.swift b/Modules/Sources/WordPressKit/WordPressAPIError.swift new file mode 100644 index 000000000000..cd8493f0b2a9 --- /dev/null +++ b/Modules/Sources/WordPressKit/WordPressAPIError.swift @@ -0,0 +1,73 @@ +import Foundation + +@frozen public enum WordPressAPIError: Error where EndpointError: LocalizedError { + static var unknownErrorMessage: String { + NSLocalizedString( + "wordpress-api.error.unknown", + value: "Something went wrong, please try again later.", + comment: "Error message that describes an unknown error had occured" + ) + } + + /// Can't encode the request arguments into a valid HTTP request. This is a programming error. + case requestEncodingFailure(underlyingError: Error) + /// Error occured in the HTTP connection. + case connection(URLError) + /// The API call returned an error result. For example, an OAuth endpoint may return an 'incorrect username or password' error, an upload media endpoint may return an 'unsupported media type' error. + case endpointError(EndpointError) + /// The API call returned an status code that's unacceptable to the endpoint. + case unacceptableStatusCode(response: HTTPURLResponse, body: Data) + /// The API call returned an HTTP response that WordPressKit can't parse. Receiving this error could be an indicator that there is an error response that's not handled properly by WordPressKit. + case unparsableResponse(response: HTTPURLResponse?, body: Data?, underlyingError: Error) + /// Other error occured. + case unknown(underlyingError: Error) + + static func unparsableResponse(response: HTTPURLResponse?, body: Data?) -> Self { + return WordPressAPIError.unparsableResponse(response: response, body: body, underlyingError: URLError(.cannotParseResponse)) + } + + var response: HTTPURLResponse? { + switch self { + case .requestEncodingFailure, .connection, .unknown: + return nil + case let .endpointError(error): + return (error as? HTTPURLResponseProviding)?.httpResponse + case .unacceptableStatusCode(let response, _): + return response + case .unparsableResponse(let response, _, _): + return response + } + } +} + +extension WordPressAPIError: LocalizedError { + + public var errorDescription: String? { + // Considering `WordPressAPIError` is the error that's surfaced from this library to the apps, its instanes + // may be displayed on UI directly. To prevent Swift's default error message (i.e. "This operation can't be + // completed. (code=...)") from being displayed, we need to make sure this implementation + // always returns a non-nil value. + let localizedErrorMessage: String + switch self { + case .requestEncodingFailure, .unparsableResponse, .unacceptableStatusCode: + // These are usually programming errors. + localizedErrorMessage = Self.unknownErrorMessage + case let .endpointError(error): + localizedErrorMessage = error.errorDescription ?? Self.unknownErrorMessage + case let .connection(error): + localizedErrorMessage = error.localizedDescription + case let .unknown(underlyingError): + if let msg = (underlyingError as? LocalizedError)?.errorDescription { + localizedErrorMessage = msg + } else { + localizedErrorMessage = Self.unknownErrorMessage + } + } + return localizedErrorMessage + } + +} + +protocol HTTPURLResponseProviding { + var httpResponse: HTTPURLResponse? { get } +} diff --git a/Modules/Sources/WordPressKit/WordPressComLanguageDatabase.swift b/Modules/Sources/WordPressKit/WordPressComLanguageDatabase.swift new file mode 100644 index 000000000000..d52e4b911c71 --- /dev/null +++ b/Modules/Sources/WordPressKit/WordPressComLanguageDatabase.swift @@ -0,0 +1,362 @@ +import Foundation + +/// This helper class allows us to map WordPress.com LanguageID's into human readable language strings. +/// +class WordPressComLanguageDatabase: NSObject { + // MARK: - Properties + + /// Languages considered 'popular' + /// + let popular: [Language] + + /// Every supported language + /// + let all: [Language] + + /// Returns both, Popular and All languages, grouped + /// + let grouped: [[Language]] + + // MARK: - Methods + + /// Designated Initializer: will load the languages contained within the `Languages.json` file. + /// + override init() { + // Parse the json file + let raw = languagesJSON.data(using: .utf8)! + let parsed = try! JSONSerialization.jsonObject(with: raw, options: [.mutableContainers, .mutableLeaves]) as? NSDictionary + + // Parse All + Popular: All doesn't contain Popular. Otherwise the json would have dupe data. Right? + let parsedAll = Language.fromArray(parsed![Keys.all] as! [[String: Any]]) + let parsedPopular = Language.fromArray(parsed![Keys.popular] as! [[String: Any]]) + let merged = parsedAll + parsedPopular + + // Done! + popular = parsedPopular + all = merged.sorted { $0.name < $1.name } + grouped = [popular] + [all] + } + + /// Returns the Human Readable name for a given Language Identifier + /// + /// - Parameter languageId: The Identifier of the language. + /// + /// - Returns: A string containing the language name, or an empty string, in case it wasn't found. + /// + @objc func nameForLanguageWithId(_ languageId: Int) -> String { + return find(id: languageId)?.name ?? "" + } + + /// Returns the Language with a given Language Identifier + /// + /// - Parameter id: The Identifier of the language. + /// + /// - Returns: The language with the matching Identifier, or nil, in case it wasn't found. + /// + func find(id: Int) -> Language? { + return all.first(where: { $0.id == id }) + } + + /// Returns the current device language as the corresponding WordPress.com language ID. + /// If the language is not supported, it returns 1 (English). + /// + /// This is a wrapper for Objective-C, Swift code should use deviceLanguage directly. + /// + @objc(deviceLanguageId) + func deviceLanguageIdNumber() -> NSNumber { + return NSNumber(value: deviceLanguage.id) + } + + /// Returns the slug string for the current device language. + /// If the language is not supported, it returns "en" (English). + /// + /// This is a wrapper for Objective-C, Swift code should use deviceLanguage directly. + /// + @objc(deviceLanguageSlug) + func deviceLanguageSlugString() -> String { + return deviceLanguage.slug + } + + /// Returns the current device language as the corresponding WordPress.com language. + /// If the language is not supported, it returns English. + /// + var deviceLanguage: Language { + let variants = LanguageTagVariants(string: deviceLanguageCode) + for variant in variants { + if let match = self.languageWithSlug(variant) { + return match + } + } + return languageWithSlug("en")! + } + + /// Searches for a WordPress.com language that matches a language tag. + /// + fileprivate func languageWithSlug(_ slug: String) -> Language? { + let search = languageCodeReplacements[slug] ?? slug + + // Use lazy evaluation so we stop filtering as soon as we got the first match + return all.lazy.filter({ $0.slug == search }).first + } + + /// Overrides the device language. For testing purposes only. + /// + @objc func _overrideDeviceLanguageCode(_ code: String) { + deviceLanguageCode = code.lowercased() + } + + // MARK: - Nested Classes + + /// Represents a Language supported by WordPress.com + /// + class Language: Equatable { + /// Language Unique Identifier + /// + let id: Int + + /// Human readable Language name + /// + let name: String + + /// Language's Slug String + /// + let slug: String + + /// Localized description for the current language + /// + var description: String { + return (Locale.current as NSLocale).displayName(forKey: NSLocale.Key.identifier, value: slug) ?? name + } + + /// Designated initializer. Will fail if any of the required properties is missing + /// + init?(dict: [String: Any]) { + guard let unwrappedId = (dict[Keys.identifier] as? NSNumber)?.intValue, + let unwrappedSlug = dict[Keys.slug] as? String, + let unwrappedName = dict[Keys.name] as? String else { + id = Int.min + name = String() + slug = String() + return nil + } + + id = unwrappedId + name = unwrappedName + slug = unwrappedSlug + } + + /// Given an array of raw languages, will return a parsed array. + /// + static func fromArray(_ array: [[String: Any]] ) -> [Language] { + return array.compactMap { + return Language(dict: $0) + } + } + + static func == (lhs: Language, rhs: Language) -> Bool { + return lhs.id == rhs.id + } + } + + // MARK: - Private Variables + + /// The device's current preferred language, or English if there's no preferred language. + /// + fileprivate lazy var deviceLanguageCode: String = { + return NSLocale.preferredLanguages.first?.lowercased() ?? "en" + }() + + // MARK: - Private Constants + fileprivate let filename = "Languages" + + // (@koke 2016-04-29) I'm not sure how correct this mapping is, but it matches + // what we do for the app translations, so they will at least be consistent + fileprivate let languageCodeReplacements: [String: String] = [ + "zh-hans": "zh-cn", + "zh-hant": "zh-tw" + ] + + // MARK: - Private Nested Structures + + /// Keys used to parse the raw languages. + /// + fileprivate struct Keys { + // swiftlint:disable operator_usage_whitespace + static let popular = "popular" + static let all = "all" + static let identifier = "i" + static let slug = "s" + static let name = "n" + // swiftlint:enable operator_usage_whitespace + } +} + +/// Provides a sequence of language tags from the specified string, from more to less specific +/// For instance, "zh-Hans-HK" will yield `["zh-Hans-HK", "zh-Hans", "zh"]` +/// +private struct LanguageTagVariants: Sequence { + let string: String + + func makeIterator() -> AnyIterator { + var components = string.components(separatedBy: "-") + return AnyIterator { + guard !components.isEmpty else { + return nil + } + + let current = components.joined(separator: "-") + components.removeLast() + + return current + } + } +} + +private let languagesJSON = """ +{ + "popular" : [ + { "i": 1, "s": "en", "n": "English" }, + { "i": 19, "s": "es", "n": "Español" }, + { "i": 438, "s": "pt-br", "n": "Português do Brasil" }, + { "i": 15, "s": "de", "n": "Deutsch" }, + { "i": 24, "s": "fr", "n": "Français" }, + { "i": 29, "s": "he", "n": "עברית" }, + { "i": 36, "s": "ja", "n": "日本語" }, + { "i": 35, "s": "it", "n": "Italiano" }, + { "i": 49, "s": "nl", "n": "Nederlands" }, + { "i": 62, "s": "ru", "n": "Русский" }, + { "i": 78, "s": "tr", "n": "Türkçe" }, + { "i": 33, "s": "id", "n": "Bahasa Indonesia" }, + { "i": 449, "s": "zh-cn", "n": "中文(简体)" }, + { "i": 452, "s": "zh-tw", "n": "中文(繁體)" }, + { "i": 40, "s": "ko", "n": "한국어" } + ], + "all" : [ + { "i": 2, "s": "af", "n": "Afrikaans" }, + { "i": 418, "s": "als", "n": "Alemannisch" }, + { "i": 481, "s": "am", "n": "Amharic" }, + { "i": 3, "s": "ar", "n": "العربية" }, + { "i": 419, "s": "arc", "n": "ܕܥܒܪܸܝܛ" }, + { "i": 4, "s": "as", "n": "অসমীয়া" }, + { "i": 420, "s": "ast", "n": "Asturianu" }, + { "i": 421, "s": "av", "n": "Авар" }, + { "i": 422, "s": "ay", "n": "Aymar" }, + { "i": 79, "s": "az", "n": "Azərbaycan" }, + { "i": 423, "s": "ba", "n": "Башҡорт" }, + { "i": 5, "s": "be", "n": "Беларуская" }, + { "i": 6, "s": "bg", "n": "Български" }, + { "i": 7, "s": "bm", "n": "Bamanankan" }, + { "i": 8, "s": "bn", "n": "বাংলা" }, + { "i": 9, "s": "bo", "n": "བོད་ཡིག" }, + { "i": 424, "s": "br", "n": "Brezhoneg" }, + { "i": 454, "s": "bs", "n": "Bosanski" }, + { "i": 10, "s": "ca", "n": "Català" }, + { "i": 425, "s": "ce", "n": "Нохчийн" }, + { "i": 11, "s": "cs", "n": "Česky" }, + { "i": 12, "s": "csb", "n": "Kaszëbsczi" }, + { "i": 426, "s": "cv", "n": "Чӑваш" }, + { "i": 13, "s": "cy", "n": "Cymraeg" }, + { "i": 14, "s": "da", "n": "Dansk" }, + { "i": 427, "s": "dv", "n": "Divehi" }, + { "i": 16, "s": "dz", "n": "ཇོང་ཁ" }, + { "i": 17, "s": "el", "n": "Ελληνικά" }, + { "i": 468, "s": "el-po", "n": "Greek-polytonic" }, + { "i": 18, "s": "eo", "n": "Esperanto" }, + { "i": 20, "s": "et", "n": "Eesti" }, + { "i": 429, "s": "eu", "n": "Euskara" }, + { "i": 21, "s": "fa", "n": "فارسی" }, + { "i": 22, "s": "fi", "n": "Suomi" }, + { "i": 473, "s": "fil", "n": "Filipino" }, + { "i": 23, "s": "fo", "n": "Føroyskt" }, + { "i": 478, "s": "fr-be", "n": "Français de Belgique" }, + { "i": 475, "s": "fr-ca", "n": "Français (Canada)" }, + { "i": 474, "s": "fr-ch", "n": "Français de Suisse" }, + { "i": 25, "s": "fur", "n": "Furlan" }, + { "i": 26, "s": "fy", "n": "Frysk" }, + { "i": 27, "s": "ga", "n": "Gaeilge" }, + { "i": 476, "s": "gd", "n": "Gàidhlig" }, + { "i": 457, "s": "gl", "n": "Galego" }, + { "i": 430, "s": "gn", "n": "Avañeẽ" }, + { "i": 28, "s": "gu", "n": "ગુજરાતી" }, + { "i": 30, "s": "hi", "n": "हिन्दी" }, + { "i": 431, "s": "hr", "n": "Hrvatski" }, + { "i": 31, "s": "hu", "n": "Magyar" }, + { "i": 467, "s": "hy", "n": "Armenian" }, + { "i": 32, "s": "ia", "n": "Interlingua" }, + { "i": 432, "s": "ii", "n": "ꆇꉙ" }, + { "i": 469, "s": "ilo", "n": "Ilokano" }, + { "i": 34, "s": "is", "n": "Íslenska" }, + { "i": 37, "s": "ka", "n": "ქართული" }, + { "i": 462, "s": "kk", "n": "Қазақ тілі" }, + { "i": 38, "s": "km", "n": "ភាសាខ្មែរ" }, + { "i": 39, "s": "kn", "n": "ಕನ್ನಡ" }, + { "i": 433, "s": "ks", "n": "कश्मीरी - (كشميري)" }, + { "i": 41, "s": "ku", "n": "Kurdî / كوردي" }, + { "i": 434, "s": "kv", "n": "Коми" }, + { "i": 479, "s": "ky", "n": "кыргыз тили" }, + { "i": 42, "s": "la", "n": "Latina" }, + { "i": 43, "s": "li", "n": "Limburgs" }, + { "i": 44, "s": "lo", "n": "ລາວ" }, + { "i": 45, "s": "lt", "n": "Lietuvių" }, + { "i": 453, "s": "lv", "n": "Latviešu valoda" }, + { "i": 435, "s": "mk", "n": "Македонски" }, + { "i": 46, "s": "ml", "n": "മലയാളം" }, + { "i": 472, "s": "mn", "n": "монгол хэл" }, + { "i": 461, "s": "mr", "n": "मराठी Marāṭhī" }, + { "i": 47, "s": "ms", "n": "Bahasa Melayu" }, + { "i": 465, "s": "mt", "n": "Malti" }, + { "i": 464, "s": "mwl", "n": "Mirandés" }, + { "i": 436, "s": "nah", "n": "Nahuatl" }, + { "i": 437, "s": "nap", "n": "Nnapulitano" }, + { "i": 48, "s": "nds", "n": "Plattdüütsch" }, + { "i": 456, "s": "ne", "n": "Nepali" }, + { "i": 50, "s": "nn", "n": "Norsk (nynorsk)" }, + { "i": 51, "s": "no", "n": "Norsk (bokmål)" }, + { "i": 52, "s": "non", "n": "Norrǿna" }, + { "i": 53, "s": "nv", "n": "Diné bizaad" }, + { "i": 54, "s": "oc", "n": "Occitan" }, + { "i": 55, "s": "or", "n": "ଓଡ଼ିଆ" }, + { "i": 56, "s": "os", "n": "Иронау" }, + { "i": 57, "s": "pa", "n": "ਪੰਜਾਬੀ" }, + { "i": 58, "s": "pl", "n": "Polski" }, + { "i": 59, "s": "ps", "n": "پښتو" }, + { "i": 60, "s": "pt", "n": "Português" }, + { "i": 439, "s": "qu", "n": "Runa Simi" }, + { "i": 61, "s": "ro", "n": "Română" }, + { "i": 483, "s": "rup", "n": "Armãneashce" }, + { "i": 63, "s": "sc", "n": "Sardu" }, + { "i": 440, "s": "sd", "n": "سنڌي" }, + { "i": 471, "s": "si", "n": "Sinhala" }, + { "i": 64, "s": "sk", "n": "Slovenčina" }, + { "i": 65, "s": "sl", "n": "Slovenščina" }, + { "i": 459, "s": "so", "n": "Somali" }, + { "i": 66, "s": "sq", "n": "Shqip" }, + { "i": 67, "s": "sr", "n": "Српски / Srpski" }, + { "i": 441, "s": "su", "n": "Basa Sunda" }, + { "i": 68, "s": "sv", "n": "Svenska" }, + { "i": 69, "s": "ta", "n": "தமிழ்" }, + { "i": 70, "s": "te", "n": "తెలుగు" }, + { "i": 71, "s": "th", "n": "ไทย" }, + { "i": 480, "s": "tir", "n": "Tigrigna" }, + { "i": 455, "s": "tl", "n": "Tagalog" }, + { "i": 72, "s": "tt", "n": "Tatarça" }, + { "i": 442, "s": "ty", "n": "Reo Mā`ohi" }, + { "i": 443, "s": "udm", "n": "Удмурт" }, + { "i": 444, "s": "ug", "n": "Uyghur"}, + { "i": 73, "s": "uk", "n": "Українська" }, + { "i": 74, "s": "ur", "n": "اردو" }, + { "i": 458, "s": "uz", "n": "Uzbek" }, + { "i": 463, "s": "va", "n": "valencià" }, + { "i": 445, "s": "vec", "n": "Vèneto" }, + { "i": 446, "s": "vi", "n": "Tiếng Việt" }, + { "i": 75, "s": "wa", "n": "Walon" }, + { "i": 447, "s": "xal", "n": "Хальмг" }, + { "i": 76, "s": "yi", "n": "ייִדיש" }, + { "i": 477, "s": "yo", "n": "èdè Yorùbá" }, + { "i": 448, "s": "za", "n": "Zhuang (Cuengh)" }, + { "i": 77, "s": "zh", "n": "中文" }, + { "i": 450, "s": "zh-hk", "n": "中文(繁體)" }, + { "i": 451, "s": "zh-sg", "n": "中文(简体)" } + ] +} +""" diff --git a/Modules/Sources/WordPressKit/WordPressComOAuthClient.swift b/Modules/Sources/WordPressKit/WordPressComOAuthClient.swift new file mode 100644 index 000000000000..3060708b86e6 --- /dev/null +++ b/Modules/Sources/WordPressKit/WordPressComOAuthClient.swift @@ -0,0 +1,812 @@ +import Foundation + +public typealias WordPressComOAuthError = WordPressAPIError + +public extension WordPressComOAuthError { + var authenticationFailureKind: AuthenticationFailure.Kind? { + if case let .endpointError(failure) = self { + return failure.kind + } + return nil + } +} + +public struct AuthenticationFailure: LocalizedError { + private static let errorsMap: [String: AuthenticationFailure.Kind] = [ + "invalid_client": .invalidClient, + "unsupported_grant_type": .unsupportedGrantType, + "invalid_request": .invalidRequest, + "needs_2fa": .needsMultifactorCode, + "invalid_otp": .invalidOneTimePassword, + "user_exists": .socialLoginExistingUserUnconnected, + "invalid_two_step_code": .invalidTwoStepCode, + "unknown_user": .unknownUser + ] + + public enum Kind { + /// client_id is missing or wrong, it shouldn't happen + case invalidClient + /// client_id doesn't support password grants + case unsupportedGrantType + /// A required field is missing/malformed + case invalidRequest + /// Multifactor Authentication code is required + case needsMultifactorCode + /// Supplied one time password is incorrect + case invalidOneTimePassword + /// Returned by the social login endpoint if a wpcom user is found, but not connected to a social service. + case socialLoginExistingUserUnconnected + /// Supplied MFA code is incorrect + case invalidTwoStepCode + case unknownUser + case unknown + } + + public var kind: Kind + public var localizedErrorMessage: String? + public var newNonce: String? + public var originalErrorJSON: [String: AnyObject] + + init(response: HTTPURLResponse, body: Data) throws { + guard [400, 409, 403].contains(response.statusCode) else { + throw URLError(.cannotParseResponse) + } + + let responseObject = try JSONSerialization.jsonObject(with: body, options: .allowFragments) + + guard let responseDictionary = responseObject as? [String: AnyObject] else { + throw URLError(.cannotParseResponse) + } + + self.init(apiJSONResponse: responseDictionary) + } + + init(apiJSONResponse responseDict: [String: AnyObject]) { + originalErrorJSON = responseDict + + // there's either a data object, or an error. + if let errorStr = responseDict["error"] as? String { + kind = Self.errorsMap[errorStr] ?? .unknown + localizedErrorMessage = responseDict["error_description"] as? String + } else if let data = responseDict["data"] as? [String: AnyObject], + let errors = data["errors"] as? NSArray, + let err = errors.firstObject as? [String: AnyObject] { + let errorCode = err["code"] as? String ?? "" + kind = Self.errorsMap[errorCode] ?? .unknown + localizedErrorMessage = err["message"] as? String + newNonce = data["two_step_nonce"] as? String + } else { + kind = .unknown + } + } +} + +/// `WordPressComOAuthClient` encapsulates the pattern of authenticating against WordPress.com OAuth2 service. +/// +/// Right now it requires a special client id and secret, so this probably won't work for you +/// @see https://developer.wordpress.com/docs/oauth2/ +/// +public final class WordPressComOAuthClient: NSObject { + + @objc public static let WordPressComOAuthDefaultBaseURL = URL(string: "https://wordpress.com")! + @objc public static let WordPressComOAuthDefaultApiBaseURL = URL(string: "https://public-api.wordpress.com")! + + @objc public static let WordPressComSocialLoginEndpointVersion = 1.0 + + private let clientID: String + private let secret: String + + private let wordPressComBaseURL: URL + private let wordPressComApiBaseURL: URL + + // Question: Is it necessary to use these many URLSession instances? + private let oauth2Session = WordPressComOAuthClient.urlSession() + private let webAuthnSession = WordPressComOAuthClient.urlSession() + private let socialSession = WordPressComOAuthClient.urlSession() + private let social2FASession = WordPressComOAuthClient.urlSession() + private let socialNewSMS2FASession = WordPressComOAuthClient.urlSession() + + private class func urlSession() -> URLSession { + let configuration = URLSessionConfiguration.ephemeral + configuration.httpAdditionalHeaders = ["Accept": "application/json"] + return URLSession(configuration: configuration) + } + + /// Creates a WordPresComOAuthClient initialized with the clientID and secrets provided + /// + @objc public class func client(clientID: String, secret: String) -> WordPressComOAuthClient { + WordPressComOAuthClient(clientID: clientID, secret: secret) + } + + /// Creates a WordPresComOAuthClient initialized with the clientID, secret and base urls provided + /// + @objc public class func client(clientID: String, + secret: String, + wordPressComBaseURL: URL, + wordPressComApiBaseURL: URL) -> WordPressComOAuthClient { + WordPressComOAuthClient( + clientID: clientID, + secret: secret, + wordPressComBaseURL: wordPressComBaseURL, + wordPressComApiBaseURL: wordPressComApiBaseURL + ) + } + + /// Creates a WordPressComOAuthClient using the defined clientID and secret + /// + /// - Parameters: + /// - clientID: the app oauth clientID + /// - secret: the app secret + /// - wordPressComBaseURL: The base url to use for WordPress.com requests. Defaults to https://wordpress.com + /// - wordPressComApiBaseURL: The base url to use for WordPress.com API requests. Defaults to https://public-api-wordpress.com + /// + @objc public init(clientID: String, + secret: String, + wordPressComBaseURL: URL = WordPressComOAuthClient.WordPressComOAuthDefaultBaseURL, + wordPressComApiBaseURL: URL = WordPressComOAuthClient.WordPressComOAuthDefaultApiBaseURL) { + self.clientID = clientID + self.secret = secret + self.wordPressComBaseURL = wordPressComBaseURL + self.wordPressComApiBaseURL = wordPressComApiBaseURL + } + + public enum AuthenticationResult { + case authenticated(token: String) + case needsMultiFactor(userID: Int, nonceInfo: SocialLogin2FANonceInfo) + } + + /// Authenticates on WordPress.com using the OAuth endpoints. + /// + /// - Parameters: + /// - username: the account's username. + /// - password: the account's password. + /// - multifactorCode: Multifactor Authentication One-Time-Password. If not needed, can be nil + public func authenticate( + username: String, + password: String, + multifactorCode: String? + ) async -> WordPressAPIResult { + var form = [ + "username": username, + "password": password, + "grant_type": "password", + "client_id": clientID, + "client_secret": secret, + "wpcom_supports_2fa": "true", + "with_auth_types": "true" + ] + + if let multifactorCode, !multifactorCode.isEmpty { + form["wpcom_otp"] = multifactorCode + } + + let builder = tokenRequestBuilder().body(form: form) + return await oauth2Session + .perform(request: builder) + .mapUnacceptableStatusCodeError(AuthenticationFailure.init(response:body:)) + .mapSuccess { response in + let responseObject = try JSONSerialization.jsonObject(with: response.body) + + // WPKitLogVerbose("Received OAuth2 response: \(self.cleanedUpResponseForLogging(responseObject as AnyObject? ?? "nil" as AnyObject))") + + guard let responseDictionary = responseObject as? [String: AnyObject] else { + throw URLError(.cannotParseResponse) + } + + // If we found an access_token, we are authed. + if let authToken = responseDictionary["access_token"] as? String { + return .authenticated(token: authToken) + } + + // If there is no access token, check for a security key nonce + guard let responseData = responseDictionary["data"] as? [String: AnyObject], + let userID = responseData["user_id"] as? Int, + let _ = responseData["two_step_nonce_webauthn"] else { + throw URLError(.cannotParseResponse) + } + + let nonceInfo = self.extractNonceInfo(data: responseData) + + return .needsMultiFactor(userID: userID, nonceInfo: nonceInfo) + } + } + + /// Authenticates on WordPress.com using the OAuth endpoints. + /// + /// - Parameters: + /// - username: the account's username. + /// - password: the account's password. + /// - multifactorCode: Multifactor Authentication One-Time-Password. If not needed, can be nil + /// - needsMultifactor: @escaping (_ userID: Int, _ nonceInfo: SocialLogin2FANonceInfo) -> Void, + /// - success: block to be called if authentication was successful. The OAuth2 token is passed as a parameter. + /// - failure: block to be called if authentication failed. The error object is passed as a parameter. + public func authenticate( + username: String, + password: String, + multifactorCode: String?, + needsMultifactor: @escaping ((_ userID: Int, _ nonceInfo: SocialLogin2FANonceInfo) -> Void), + success: @escaping (_ authToken: String?) -> Void, + failure: @escaping (_ error: WordPressComOAuthError) -> Void + ) { + Task { @MainActor in + let result = await authenticate(username: username, password: password, multifactorCode: multifactorCode) + switch result { + case let .success(.authenticated(token)): + success(token) + case let .success(.needsMultiFactor(userID, nonceInfo)): + needsMultifactor(userID, nonceInfo) + case let .failure(error): + failure(error) + } + } + } + + /// Requests a One Time Code, to be sent via SMS. + /// + /// - Parameters: + /// - username: the account's username. + /// - password: the account's password. + /// - success: block to be called if authentication was successful. + /// - failure: block to be called if authentication failed. The error object is passed as a parameter. + public func requestOneTimeCode(username: String, password: String) async -> WordPressAPIResult { + let builder = tokenRequestBuilder() + .body(form: [ + "username": username, + "password": password, + "grant_type": "password", + "client_id": clientID, + "client_secret": secret, + "wpcom_supports_2fa": "true", + "wpcom_resend_otp": "true" + ]) + return await oauth2Session + .perform(request: builder) + .mapUnacceptableStatusCodeError(AuthenticationFailure.init(response:body:)) + .mapSuccess { _ in () } + } + + /// Requests a One Time Code, to be sent via SMS. + /// + /// - Parameters: + /// - username: the account's username. + /// - password: the account's password. + /// - success: block to be called if authentication was successful. + /// - failure: block to be called if authentication failed. The error object is passed as a parameter. + public func requestOneTimeCode( + username: String, + password: String, + success: @escaping () -> Void, + failure: @escaping (_ error: WordPressComOAuthError) -> Void + ) { + Task { @MainActor in + await requestOneTimeCode(username: username, password: password) + .execute(onSuccess: success, onFailure: failure) + } + } + + /// Request a new SMS code to be sent during social login + /// + /// - Parameters: + /// - userID: The wpcom user id. + /// - nonce: The nonce from a social login attempt. + public func requestSocial2FACode( + userID: Int, + nonce: String + ) async -> WordPressAPIResult { + let builder = socialSignInRequestBuilder(action: .sendOTPViaSMS) + .body( + form: [ + "user_id": "\(userID)", + "two_step_nonce": nonce, + "client_id": clientID, + "client_secret": secret, + "wpcom_supports_2fa": "true", + "wpcom_resend_otp": "true" + ] + ) + + return await socialNewSMS2FASession + .perform(request: builder, errorType: AuthenticationFailure.self) + .mapUnacceptableStatusCodeError(AuthenticationFailure.init(response:body:)) + .mapSuccess { response -> String in + guard let responseObject = try? JSONSerialization.jsonObject(with: response.body), + let responseDictionary = responseObject as? [String: AnyObject], + let responseData = responseDictionary["data"] as? [String: AnyObject] else { + throw URLError(.cannotParseResponse) + } + + return self.extractNonceInfo(data: responseData).nonceSMS + } + .flatMapError { error in + if case let .endpointError(authenticationFailure) = error, let newNonce = authenticationFailure.newNonce { + return .success(newNonce) + } + return .failure(error) + } + } + + /// Request a new SMS code to be sent during social login + /// + /// - Parameters: + /// - userID: The wpcom user id. + /// - nonce: The nonce from a social login attempt. + /// - success: block to be called if authentication was successful. + /// - failure: block to be called if authentication failed. The error object is passed as a parameter. + public func requestSocial2FACode( + userID: Int, + nonce: String, + success: @escaping (_ newNonce: String) -> Void, + failure: @escaping (_ error: WordPressComOAuthError, _ newNonce: String?) -> Void + ) { + Task { @MainActor in + let result = await requestSocial2FACode(userID: userID, nonce: nonce) + switch result { + case let .success(newNonce): + success(newNonce) + case let .failure(error): + // TODO: Remove the `newNonce` argument? + failure(error, nil) + } + } + } + + public enum SocialAuthenticationResult { + case authenticated(token: String) + case needsMultiFactor(userID: Int, nonceInfo: SocialLogin2FANonceInfo) + case existingUserNeedsConnection(email: String) + } + + /// Authenticate on WordPress.com with a social service's ID token. + /// + /// - Parameters: + /// - token: A social ID token obtained from a supported social service. + /// - service: The social service type (ex: "google" or "apple"). + public func authenticate( + socialIDToken token: String, + service: String + ) async -> WordPressAPIResult { + let builder = socialSignInRequestBuilder(action: .authenticate) + .body( + form: [ + "client_id": clientID, + "client_secret": secret, + "service": service, + "get_bearer_token": "true", + "id_token": token + ] + ) + + return await socialSession + .perform(request: builder, errorType: AuthenticationFailure.self) + .mapUnacceptableStatusCodeError(AuthenticationFailure.init(response:body:)) + .mapSuccess { response in + // WPKitLogVerbose("Received Social Login Oauth response.") + + // Make sure we received expected data. + let responseObject = try? JSONSerialization.jsonObject(with: response.body) + guard let responseDictionary = responseObject as? [String: AnyObject], + let responseData = responseDictionary["data"] as? [String: AnyObject] else { + throw URLError(.cannotParseResponse) + } + + // Check for a bearer token. If one is found then we're authed. + if let authToken = responseData["bearer_token"] as? String { + return .authenticated(token: authToken) + } + + // If there is no bearer token, check for 2fa enabled. + guard let userID = responseData["user_id"] as? Int, + let _ = responseData["two_step_nonce_backup"] else { + throw URLError(.cannotParseResponse) + } + + let nonceInfo = self.extractNonceInfo(data: responseData) + return .needsMultiFactor(userID: userID, nonceInfo: nonceInfo) + } + .flatMapError { error in + // Inspect the error and handle the case of an existing user. + if case let .endpointError(authenticationFailure) = error, authenticationFailure.kind == .socialLoginExistingUserUnconnected { + // Get the responseObject from the userInfo dict. + // Extract the email address for the callback. + let responseDict = authenticationFailure.originalErrorJSON + if let data = responseDict["data"] as? [String: AnyObject], + let email = data["email"] as? String { + return .success(.existingUserNeedsConnection(email: email)) + } + } + return .failure(error) + } + } + + /// Authenticate on WordPress.com with a social service's ID token. + /// + /// - Parameters: + /// - token: A social ID token obtained from a supported social service. + /// - service: The social service type (ex: "google" or "apple"). + /// - success: block to be called if authentication was successful. The OAuth2 token is passed as a parameter. + /// - needsMultifactor: block to be called if a 2fa token is needed to complete the auth process. + /// - failure: block to be called if authentication failed. The error object is passed as a parameter. + public func authenticate( + socialIDToken token: String, + service: String, + success: @escaping (_ authToken: String?) -> Void, + needsMultifactor: @escaping (_ userID: Int, _ nonceInfo: SocialLogin2FANonceInfo) -> Void, + existingUserNeedsConnection: @escaping (_ email: String) -> Void, + failure: @escaping (_ error: WordPressComOAuthError) -> Void + ) { + Task { @MainActor in + let result = await self.authenticate(socialIDToken: token, service: service) + switch result { + case let .success(.authenticated(token)): + success(token) + case let .success(.needsMultiFactor(userID, nonceInfo)): + needsMultifactor(userID, nonceInfo) + case let .success(.existingUserNeedsConnection(email)): + existingUserNeedsConnection(email) + case let .failure(error): + failure(error) + } + } + } + + /// Request a security key challenge from WordPress.com to be signed by the client. + /// + /// - Parameters: + /// - userID: the wpcom userID + /// - twoStepNonce: The nonce returned from a log in attempt. + public func requestWebauthnChallenge( + userID: Int64, + twoStepNonce: String + ) async -> WordPressAPIResult { + let builder = webAuthnRequestBuilder(action: .requestChallenge) + .body(form: [ + "user_id": "\(userID)", + "client_id": clientID, + "client_secret": secret, + "auth_type": "webauthn", + "two_step_nonce": twoStepNonce, + ]) + return await webAuthnSession + .perform(request: builder) + .mapUnacceptableStatusCodeError(AuthenticationFailure.init(response:body:)) + .mapSuccess { response in + // Expect the parent data response object + let responseObject = try? JSONSerialization.jsonObject(with: response.body) + guard let responseDictionary = responseObject as? [String: Any], + let responseData = responseDictionary["data"] as? [String: Any] else { + throw URLError(.cannotParseResponse) + } + + // Expect the challenge info. + guard + let challenge = responseData["challenge"] as? String, + let nonce = responseData["two_step_nonce"] as? String, + let rpID = responseData["rpId"] as? String, + let allowCredentials = responseData["allowCredentials"] as? [[String: Any]] + else { + throw URLError(.cannotParseResponse) + } + + let allowedCredentialIDs = allowCredentials.compactMap { $0["id"] as? String } + return WebauthnChallengeInfo(challenge: challenge, rpID: rpID, twoStepNonce: nonce, allowedCredentialIDs: allowedCredentialIDs) + } + } + + /// Request a security key challenge from WordPress.com to be signed by the client. + /// + /// - Parameters: + /// - userID: the wpcom userID + /// - twoStepNonce: The nonce returned from a log in attempt. + /// - success: block to be called if authentication was successful. The challenge info is passed as a parameter. + /// - failure: block to be called if authentication failed. The error object is passed as a parameter. + public func requestWebauthnChallenge( + userID: Int64, + twoStepNonce: String, + success: @escaping (_ challengeData: WebauthnChallengeInfo) -> Void, + failure: @escaping (_ error: WordPressComOAuthError) -> Void + ) { + Task { @MainActor in + await requestWebauthnChallenge(userID: userID, twoStepNonce: twoStepNonce) + .execute(onSuccess: success, onFailure: failure) + } + } + + /// Verifies a signed challenge with a security key on WordPress.com. + /// + /// - Parameters: + /// - userID: the wpcom userID + /// - twoStepNonce: The nonce returned from a request challenge attempt. + /// - credentialID: The id of the security key that signed the challenge. + /// - clientDataJson: Json returned by the passkey framework. + /// - authenticatorData: Authenticator Data from the security key. + /// - signature: Signature to verify. + /// - userHandle: User associated with the security key. + public func authenticateWebauthnSignature( + userID: Int64, + twoStepNonce: String, + credentialID: Data, + clientDataJson: Data, + authenticatorData: Data, + signature: Data, + userHandle: Data + ) async -> WordPressAPIResult { + let clientData: [String: AnyHashable] = [ + "id": credentialID.base64EncodedString(), + "rawId": credentialID.base64EncodedString(), + "type": "public-key", + "clientExtensionResults": Dictionary(), + "response": [ + "clientDataJSON": clientDataJson.base64EncodedString(), + "authenticatorData": authenticatorData.base64EncodedString(), + "signature": signature.base64EncodedString(), + "userHandle": userHandle.base64EncodedString(), + ] + ] + + let clientDataString: String + do { + let serializedClientData = try JSONSerialization.data(withJSONObject: clientData, options: .withoutEscapingSlashes) + guard let string = String(data: serializedClientData, encoding: .utf8) else { + throw URLError(.badURL) + } + clientDataString = string + } catch { + return .failure(.requestEncodingFailure(underlyingError: error)) + } + + let builder = webAuthnRequestBuilder(action: .authenticate) + .body(form: [ + "user_id": "\(userID)", + "client_id": clientID, + "client_secret": secret, + "auth_type": "webauthn", + "two_step_nonce": twoStepNonce, + "client_data": clientDataString, + "get_bearer_token": "true", + "create_2fa_cookies_only": "true", + ]) + + return await webAuthnSession + .perform(request: builder) + .mapUnacceptableStatusCodeError(AuthenticationFailure.init(response:body:)) + .mapSuccess { response in + let responseObject = try? JSONSerialization.jsonObject(with: response.body) + guard let responseDictionary = responseObject as? [String: Any], + let successResponse = responseDictionary["success"] as? Bool, successResponse, + let responseData = responseDictionary["data"] as? [String: Any] else { + throw URLError(.cannotParseResponse) + } + + // Check for a bearer token. If one is found then we're authed. + guard let authToken = responseData["bearer_token"] as? String else { + throw URLError(.cannotParseResponse) + } + + return authToken + } + } + + /// Verifies a signed challenge with a security key on WordPress.com. + /// + /// - Parameters: + /// - userID: the wpcom userID + /// - twoStepNonce: The nonce returned from a request challenge attempt. + /// - credentialID: The id of the security key that signed the challenge. + /// - clientDataJson: Json returned by the passkey framework. + /// - authenticatorData: Authenticator Data from the security key. + /// - signature: Signature to verify. + /// - userHandle: User associated with the security key. + /// - success: block to be called if authentication was successful. The auth token is passed as a parameter. + /// - failure: block to be called if authentication failed. The error object is passed as a parameter. + public func authenticateWebauthnSignature( + userID: Int64, + twoStepNonce: String, + credentialID: Data, + clientDataJson: Data, + authenticatorData: Data, + signature: Data, + userHandle: Data, + success: @escaping (_ authToken: String) -> Void, + failure: @escaping (_ error: WordPressComOAuthError) -> Void + ) { + Task { @MainActor in + await authenticateWebauthnSignature( + userID: userID, + twoStepNonce: twoStepNonce, + credentialID: credentialID, + clientDataJson: clientDataJson, + authenticatorData: authenticatorData, + signature: signature, + userHandle: userHandle + ) + .execute(onSuccess: success, onFailure: failure) + } + } + + /// A helper method to get an instance of SocialLogin2FANonceInfo and populate + /// it with the supplied data. + /// + /// - Parameters: + /// - data: The dictionary to use to populate the instance. + /// + /// - Return: SocialLogin2FANonceInfo + /// + private func extractNonceInfo(data: [String: AnyObject]) -> SocialLogin2FANonceInfo { + let nonceInfo = SocialLogin2FANonceInfo() + + if let nonceAuthenticator = data["two_step_nonce_authenticator"] as? String { + nonceInfo.nonceAuthenticator = nonceAuthenticator + } + + // atm, used for requesting and verifying a security key. + if let nonceWebauthn = data["two_step_nonce_webauthn"] as? String { + nonceInfo.nonceWebauthn = nonceWebauthn + } + + // atm, the only use of the more vague "two_step_nonce" key is when requesting a new SMS code + if let nonce = data["two_step_nonce"] as? String { + nonceInfo.nonceSMS = nonce + } + + if let nonce = data["two_step_nonce_sms"] as? String { + nonceInfo.nonceSMS = nonce + } + + if let nonce = data["two_step_nonce_backup"] as? String { + nonceInfo.nonceBackup = nonce + } + + if let notification = data["two_step_notification_sent"] as? String { + nonceInfo.notificationSent = notification + } + + if let authTypes = data["two_step_supported_auth_types"] as? [String] { + nonceInfo.supportedAuthTypes = authTypes + } + + if let phone = data["phone_number"] as? String { + nonceInfo.phoneNumber = phone + } + + return nonceInfo + } + + /// Completes a social login that has 2fa enabled. + /// + /// - Parameters: + /// - userID: The wpcom user id. + /// - authType: The type of 2fa authentication being used. (sms|backup|authenticator) + /// - twoStepCode: The user's 2fa code. + /// - twoStepNonce: The nonce returned from a social login attempt. + public func authenticate( + socialLoginUserID userID: Int, + authType: String, + twoStepCode: String, + twoStepNonce: String + ) async -> WordPressAPIResult { + let builder = socialSignInRequestBuilder(action: .authenticateWith2FA) + .body(form: [ + "user_id": "\(userID)", + "auth_type": authType, + "two_step_code": twoStepCode, + "two_step_nonce": twoStepNonce, + "get_bearer_token": "true", + "client_id": clientID, + "client_secret": secret + ]) + return await social2FASession + .perform(request: builder) + .mapUnacceptableStatusCodeError(AuthenticationFailure.init(response:body:)) + .mapSuccess { response in + let responseObject = try JSONSerialization.jsonObject(with: response.body) + + // WPKitLogVerbose("Received Social Login Oauth response: \(self.cleanedUpResponseForLogging(responseObject as AnyObject? ?? "nil" as AnyObject))") + guard let responseDictionary = responseObject as? [String: AnyObject], + let responseData = responseDictionary["data"] as? [String: AnyObject], + let authToken = responseData["bearer_token"] as? String else { + throw URLError(.cannotParseResponse) + } + + return authToken + } + } + + /// Completes a social login that has 2fa enabled. + /// + /// - Parameters: + /// - userID: The wpcom user id. + /// - authType: The type of 2fa authentication being used. (sms|backup|authenticator) + /// - twoStepCode: The user's 2fa code. + /// - twoStepNonce: The nonce returned from a social login attempt. + /// - success: block to be called if authentication was successful. The OAuth2 token is passed as a parameter. + /// - failure: block to be called if authentication failed. The error object is passed as a parameter. + public func authenticate( + socialLoginUserID userID: Int, + authType: String, + twoStepCode: String, + twoStepNonce: String, + success: @escaping (_ authToken: String?) -> Void, + failure: @escaping (_ error: WordPressComOAuthError) -> Void + ) { + Task { @MainActor in + await authenticate( + socialLoginUserID: userID, + authType: authType, + twoStepCode: twoStepCode, + twoStepNonce: twoStepNonce + ) + .execute(onSuccess: success, onFailure: failure) + } + } + + private func cleanedUpResponseForLogging(_ response: AnyObject) -> AnyObject { + guard var responseDictionary = response as? [String: AnyObject] else { + return response + } + + // If the response is wrapped in a "data" field, clean up tokens inside it. + if var dataDictionary = responseDictionary["data"] as? [String: AnyObject] { + let keys = ["access_token", "bearer_token", "token_links"] + for key in keys { + if dataDictionary[key] != nil { + dataDictionary[key] = "*** REDACTED ***" as AnyObject? + } + } + + responseDictionary.updateValue(dataDictionary as AnyObject, forKey: "data") + + return responseDictionary as AnyObject + } + + let keys = ["access_token", "bearer_token"] + for key in keys { + if responseDictionary[key] != nil { + responseDictionary[key] = "*** REDACTED ***" as AnyObject? + } + } + + return responseDictionary as AnyObject + } + +} + +private extension WordPressComOAuthClient { + func tokenRequestBuilder() -> HTTPRequestBuilder { + HTTPRequestBuilder(url: wordPressComApiBaseURL) + .method(.post) + .append(percentEncodedPath: "/oauth2/token") + } + + enum WebAuthnAction: String { + case requestChallenge = "webauthn-challenge-endpoint" + case authenticate = "webauthn-authentication-endpoint" + } + + func webAuthnRequestBuilder(action: WebAuthnAction) -> HTTPRequestBuilder { + HTTPRequestBuilder(url: wordPressComBaseURL) + .method(.post) + .append(percentEncodedPath: "/wp-login.php") + .query(name: "action", value: action.rawValue) + } + + enum SocialSignInAction: String { + case sendOTPViaSMS = "send-sms-code-endpoint" + case authenticate = "social-login-endpoint" + case authenticateWith2FA = "two-step-authentication-endpoint" + + var queryItems: [URLQueryItem] { + var items = [URLQueryItem(name: "action", value: rawValue)] + if self == .authenticate || self == .authenticateWith2FA { + items.append(URLQueryItem(name: "version", value: "1.0")) + } + return items + } + } + + func socialSignInRequestBuilder(action: SocialSignInAction) -> HTTPRequestBuilder { + HTTPRequestBuilder(url: wordPressComBaseURL) + .method(.post) + .append(percentEncodedPath: "/wp-login.php") + .append(query: action.queryItems, override: true) + } +} diff --git a/Modules/Sources/WordPressKit/WordPressComRestApi.swift b/Modules/Sources/WordPressKit/WordPressComRestApi.swift new file mode 100644 index 000000000000..d3e37042282d --- /dev/null +++ b/Modules/Sources/WordPressKit/WordPressComRestApi.swift @@ -0,0 +1,710 @@ +import Foundation + +// MARK: - WordPressComRestApiError + +@available(*, deprecated, renamed: "WordPressComRestApiErrorCode", message: "`WordPressComRestApiError` is renamed to `WordPressRestApiErrorCode`, and no longer conforms to `Swift.Error`") +public typealias WordPressComRestApiError = WordPressComRestApiErrorCode + +/** + Error constants for the WordPress.com REST API + + - InvalidInput: The parameters sent to the server where invalid + - InvalidToken: The token provided was invalid + - AuthorizationRequired: Permission required to access resource + - UploadFailed: The upload failed + - RequestSerializationFailed: The serialization of the request failed + - Unknown: Unknow error happen + */ +@objc public enum WordPressComRestApiErrorCode: Int, CaseIterable { + case invalidInput + case invalidToken + case authorizationRequired + case uploadFailed + case requestSerializationFailed + case responseSerializationFailed + case tooManyRequests + case unknown + case preconditionFailure + case malformedURL + case invalidQuery + case reauthorizationRequired +} + +public struct WordPressComRestApiEndpointError: Error { + public var code: WordPressComRestApiErrorCode + var response: HTTPURLResponse? + + public var apiErrorCode: String? + public var apiErrorMessage: String? + public var apiErrorData: AnyObject? + + var additionalUserInfo: [String: Any]? +} + +extension WordPressComRestApiEndpointError: LocalizedError { + public var errorDescription: String? { + apiErrorMessage + } +} + +extension WordPressComRestApiEndpointError: HTTPURLResponseProviding { + var httpResponse: HTTPURLResponse? { + response + } +} + +public enum ResponseType { + case json + case data +} + +// MARK: - WordPressComRestApi + +open class WordPressComRestApi: NSObject { + + /// Use `URLSession` directly (instead of Alamofire) to send API requests. + @available(*, deprecated, message: "This property is no longer being used because WordPressKit now sends all HTTP requests using `URLSession` directly.") + public static var useURLSession = true + + // MARK: Properties + + // swiftlint:disable operator_usage_whitespace + @objc public static let ErrorKeyErrorCode = "WordPressComRestApiErrorCodeKey" + @objc public static let ErrorKeyErrorMessage = "WordPressComRestApiErrorMessageKey" + @objc public static let ErrorKeyErrorData = "WordPressComRestApiErrorDataKey" + @objc public static let ErrorKeyErrorDataEmail = "email" + + @objc public static let LocaleKeyDefault = "locale" // locale is specified with this for v1 endpoints + @objc public static let LocaleKeyV2 = "_locale" // locale is prefixed with an underscore for v2 + // swiftlint:enable operator_usage_whitespace + + public typealias RequestEnqueuedBlock = (_ taskID: NSNumber) -> Void + public typealias SuccessResponseBlock = (_ responseObject: AnyObject, _ httpResponse: HTTPURLResponse?) -> Void + public typealias FailureReponseBlock = (_ error: NSError, _ httpResponse: HTTPURLResponse?) -> Void + public typealias APIResult = WordPressAPIResult, WordPressComRestApiEndpointError> + + @objc public static let apiBaseURL: URL = URL(string: "https://public-api.wordpress.com/")! + + @objc public static let defaultBackgroundSessionIdentifier = "org.wordpress.wpcomrestapi" + + private let oAuthToken: String? + + private let userAgent: String? + + @objc public let backgroundSessionIdentifier: String + + @objc public let sharedContainerIdentifier: String? + + private let backgroundUploads: Bool + + private let localeKey: String + + @objc public let baseURL: URL + + private var invalidTokenHandler: (() -> Void)? + + private var useEphemeralSession: Bool + + /** + Configure whether or not the user's preferred language locale should be appended. Defaults to true. + */ + @objc open var appendsPreferredLanguageLocale = true + + // MARK: WordPressComRestApi + + @objc convenience public init(oAuthToken: String? = nil, userAgent: String? = nil) { + self.init(oAuthToken: oAuthToken, userAgent: userAgent, backgroundUploads: false, backgroundSessionIdentifier: WordPressComRestApi.defaultBackgroundSessionIdentifier) + } + + @objc convenience public init(oAuthToken: String? = nil, userAgent: String? = nil, baseURL: URL = WordPressComRestApi.apiBaseURL) { + self.init(oAuthToken: oAuthToken, userAgent: userAgent, backgroundUploads: false, backgroundSessionIdentifier: WordPressComRestApi.defaultBackgroundSessionIdentifier, baseURL: baseURL) + } + + /// Creates a new API object to connect to the WordPress Rest API. + /// + /// - Parameters: + /// - oAuthToken: the oAuth token to be used for authentication. + /// - userAgent: the user agent to identify the client doing the connection. + /// - backgroundUploads: If this value is true the API object will use a background session to execute uploads requests when using the `multipartPOST` function. The default value is false. + /// - backgroundSessionIdentifier: The session identifier to use for the background session. This must be unique in the system. + /// - sharedContainerIdentifier: An optional string used when setting up background sessions for use in an app extension. Default is nil. + /// - localeKey: The key with which to specify locale in the parameters of a request. + /// - baseURL: The base url to use for API requests. Default is https://public-api.wordpress.com/ + /// + /// - Discussion: When backgroundUploads are activated any request done by the multipartPOST method will use background session. This background session is shared for all multipart + /// requests and the identifier used must be unique in the system, Apple recomends to use invert DNS base on your bundle ID. Keep in mind these requests will continue even + /// after the app is killed by the system and the system will retried them until they are done. If the background session is initiated from an app extension, you *must* provide a value + /// for the sharedContainerIdentifier. + /// + @objc public init(oAuthToken: String? = nil, userAgent: String? = nil, + backgroundUploads: Bool = false, + backgroundSessionIdentifier: String = WordPressComRestApi.defaultBackgroundSessionIdentifier, + sharedContainerIdentifier: String? = nil, + localeKey: String = WordPressComRestApi.LocaleKeyDefault, + baseURL: URL = WordPressComRestApi.apiBaseURL, + useEphemeralSession: Bool = false) { + self.oAuthToken = oAuthToken + self.userAgent = userAgent + self.backgroundUploads = backgroundUploads + self.backgroundSessionIdentifier = backgroundSessionIdentifier + self.sharedContainerIdentifier = sharedContainerIdentifier + self.localeKey = localeKey + self.baseURL = baseURL + self.useEphemeralSession = useEphemeralSession + + super.init() + } + + deinit { + for session in [urlSession, uploadURLSession] { + session.finishTasksAndInvalidate() + } + } + + /// Cancels all outgoing tasks asynchronously without invalidating the session. + public func cancelTasks() { + for session in [urlSession, uploadURLSession] { + session.getAllTasks { tasks in + tasks.forEach({ $0.cancel() }) + } + } + } + + /** + Cancels all ongoing taks and makes the session invalid so the object will not fullfil any more request + */ + @objc open func invalidateAndCancelTasks() { + for session in [urlSession, uploadURLSession] { + session.invalidateAndCancel() + } + } + + @objc open func setInvalidTokenHandler(_ handler: @escaping () -> Void) { + invalidTokenHandler = handler + } + + // MARK: Network requests + + /** + Executes a GET request to the specified endpoint defined on URLString + + - parameter URLString: the url string to be added to the baseURL + - parameter parameters: the parameters to be encoded on the request + - parameter success: callback to be called on successful request + - parameter failure: callback to be called on failed request + + - returns: a NSProgress object that can be used to track the progress of the request and to cancel the request. If the method + returns nil it's because something happened on the request serialization and the network request was not started, but the failure callback + will be invoked with the error specificing the serialization issues. + */ + @objc @discardableResult open func GET(_ URLString: String, + parameters: [String: AnyObject]?, + success: @escaping SuccessResponseBlock, + failure: @escaping FailureReponseBlock) -> Progress? { + let progress = Progress.discreteProgress(totalUnitCount: 100) + + Task { @MainActor in + let result = await self.perform(.get, URLString: URLString, parameters: parameters, fulfilling: progress) + + switch result { + case let .success(response): + success(response.body, response.response) + case let .failure(error): + failure(error.asNSError(), error.response) + } + } + + return progress + } + + open func GETData(_ URLString: String, + parameters: [String: AnyObject]?, + completion: @escaping (Swift.Result<(Data, HTTPURLResponse?), Error>) -> Void) { + Task { @MainActor in + let result = await perform(.get, URLString: URLString, parameters: parameters, fulfilling: nil, decoder: { $0 }) + + completion( + result + .map { ($0.body, $0.response) } + .eraseToError() + ) + } + } + + /** + Executes a POST request to the specified endpoint defined on URLString + + - parameter URLString: the url string to be added to the baseURL + - parameter parameters: the parameters to be encoded on the request + - parameter success: callback to be called on successful request + - parameter failure: callback to be called on failed request + + - returns: a NSProgress object that can be used to track the progress of the upload and to cancel the upload. If the method + returns nil it's because something happened on the request serialization and the network request was not started, but the failure callback + will be invoked with the error specificing the serialization issues. + */ + @objc @discardableResult open func POST(_ URLString: String, + parameters: [String: AnyObject]?, + success: @escaping SuccessResponseBlock, + failure: @escaping FailureReponseBlock) -> Progress? { + let progress = Progress.discreteProgress(totalUnitCount: 100) + + Task { @MainActor in + let result = await self.perform(.post, URLString: URLString, parameters: parameters, fulfilling: progress) + + switch result { + case let .success(response): + success(response.body, response.response) + case let .failure(error): + failure(error.asNSError(), error.response) + } + } + + return progress + } + + /** + Executes a multipart POST using the current serializer, the parameters defined and the fileParts defined in the request + This request will be streamed from disk, so it's ideally to be used for large media post uploads. + + - parameter URLString: the endpoint to connect + - parameter parameters: the parameters to use on the request + - parameter fileParts: the file parameters that are added to the multipart request + - parameter requestEnqueued: callback to be called when the fileparts are serialized and request is added to the background session. Defaults to nil + - parameter success: callback to be called on successful request + - parameter failure: callback to be called on failed request + + - returns: a `Progerss` object that can be used to track the progress of the upload and to cancel the upload. If the method + returns nil it's because something happened on the request serialization and the network request was not started, but the failure callback + will be invoked with the error specificing the serialization issues. + */ + @nonobjc @discardableResult open func multipartPOST( + _ URLString: String, + parameters: [String: AnyObject]?, + fileParts: [FilePart], + requestEnqueued: RequestEnqueuedBlock? = nil, + success: @escaping SuccessResponseBlock, + failure: @escaping FailureReponseBlock + ) -> Progress? { + let progress = Progress.discreteProgress(totalUnitCount: 100) + + Task { @MainActor in + let result = await upload(URLString: URLString, parameters: parameters, fileParts: fileParts, requestEnqueued: requestEnqueued, fulfilling: progress) + switch result { + case let .success(response): + success(response.body, response.response) + case let .failure(error): + failure(error.asNSError(), error.response) + } + } + + return progress + } + + @objc open func hasCredentials() -> Bool { + guard let authToken = oAuthToken else { + return false + } + return !(authToken.isEmpty) + } + + override open var hash: Int { + return "\(String(describing: oAuthToken)),\(String(describing: userAgent))".hashValue + } + + func requestBuilder(URLString: String) throws -> HTTPRequestBuilder { + guard let url = URL(string: URLString, relativeTo: baseURL) else { + throw URLError(.badURL) + } + + var builder = HTTPRequestBuilder(url: url) + + if appendsPreferredLanguageLocale { + let preferredLanguageIdentifier = WordPressComLanguageDatabase().deviceLanguage.slug + builder = builder.query(defaults: [URLQueryItem(name: localeKey, value: preferredLanguageIdentifier)]) + } + + return builder + } + + @objc public func temporaryFileURL(withExtension fileExtension: String) -> URL { + assert(!fileExtension.isEmpty, "file Extension cannot be empty") + let fileName = "\(ProcessInfo.processInfo.globallyUniqueString)_file.\(fileExtension)" + let fileURL = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(fileName) + return fileURL + } + + // MARK: - Async + + private lazy var urlSession: URLSession = { + URLSession(configuration: sessionConfiguration(background: false)) + }() + + private lazy var uploadURLSession: URLSession = { + let configuration = sessionConfiguration(background: backgroundUploads) + configuration.sharedContainerIdentifier = self.sharedContainerIdentifier + if configuration.identifier != nil { + return URLSession.backgroundSession(configuration: configuration) + } else { + return URLSession(configuration: configuration) + } + }() + + private func sessionConfiguration(background: Bool) -> URLSessionConfiguration { + let configuration: URLSessionConfiguration + if background { + configuration = .background(withIdentifier: self.backgroundSessionIdentifier) + } else if useEphemeralSession { + configuration = .ephemeral + } else { + configuration = .default + } + + var additionalHeaders: [String: AnyObject] = [:] + if let oAuthToken = self.oAuthToken { + additionalHeaders["Authorization"] = "Bearer \(oAuthToken)" as AnyObject + } + if let userAgent = self.userAgent { + additionalHeaders["User-Agent"] = userAgent as AnyObject + } + + configuration.httpAdditionalHeaders = additionalHeaders + + return configuration + } + + open func perform( + _ method: HTTPRequestBuilder.Method, + URLString: String, + parameters: [String: Any]? = nil, + fulfilling progress: Progress? = nil + ) async -> APIResult { + await perform(method, URLString: URLString, parameters: parameters, fulfilling: progress) { + try (JSONSerialization.jsonObject(with: $0) as AnyObject) + } + } + + open func perform( + _ method: HTTPRequestBuilder.Method, + URLString: String, + parameters: [String: Any]? = nil, + fulfilling progress: Progress? = nil, + jsonDecoder: JSONDecoder? = nil, + type: T.Type = T.self + ) async -> APIResult { + await perform(method, URLString: URLString, parameters: parameters, fulfilling: progress) { + let decoder = jsonDecoder ?? JSONDecoder() + return try decoder.decode(type, from: $0) + } + } + + private func perform( + _ method: HTTPRequestBuilder.Method, + URLString: String, + parameters: [String: Any]?, + fulfilling progress: Progress?, + decoder: @escaping (Data) throws -> T + ) async -> APIResult { + var builder: HTTPRequestBuilder + do { + builder = try requestBuilder(URLString: URLString) + .method(method) + } catch { + return .failure(.requestEncodingFailure(underlyingError: error)) + } + + if let parameters { + if builder.method.allowsHTTPBody { + builder = builder.body(json: parameters as Any) + } else { + builder = builder.query(parameters) + } + } + + return await perform(request: builder, fulfilling: progress, decoder: decoder) + } + + func perform( + request: HTTPRequestBuilder, + fulfilling progress: Progress? = nil, + decoder: @escaping (Data) throws -> T, + taskCreated: ((Int) -> Void)? = nil, + session: URLSession? = nil + ) async -> APIResult { + await (session ?? self.urlSession) + .perform(request: request, taskCreated: taskCreated, fulfilling: progress, errorType: WordPressComRestApiEndpointError.self) + .mapSuccess { response -> HTTPAPIResponse in + let object = try decoder(response.body) + + return HTTPAPIResponse(response: response.response, body: object) + } + .mapUnacceptableStatusCodeError { response, body in + if let error = self.processError(response: response, body: body, additionalUserInfo: nil) { + return error + } + + throw URLError(.cannotParseResponse) + } + .mapError { error -> WordPressAPIError in + switch error { + case .requestEncodingFailure: + return .endpointError(.init(code: .requestSerializationFailed)) + case let .unparsableResponse(response, _, _): + return .endpointError(.init(code: .responseSerializationFailed, response: response)) + default: + return error + } + } + } + + public func upload( + URLString: String, + parameters: [String: AnyObject]? = nil, + httpHeaders: [String: String]? = nil, + fileParts: [FilePart], + requestEnqueued: RequestEnqueuedBlock? = nil, + fulfilling progress: Progress? = nil + ) async -> APIResult { + let builder: HTTPRequestBuilder + do { + let form = try fileParts.map { + try MultipartFormField(fileAtPath: $0.url.path, name: $0.parameterName, filename: $0.fileName, mimeType: $0.mimeType) + } + builder = try requestBuilder(URLString: URLString) + .method(.post) + .body(form: form) + .headers(httpHeaders ?? [:]) + } catch { + return .failure(.requestEncodingFailure(underlyingError: error)) + } + + return await perform( + request: builder.query(parameters ?? [:]), + fulfilling: progress, + decoder: { try JSONSerialization.jsonObject(with: $0) as AnyObject }, + taskCreated: { taskID in + DispatchQueue.main.async { + requestEnqueued?(NSNumber(value: taskID)) + } + }, + session: uploadURLSession + ) + } + +} + +// MARK: - Error processing + +extension WordPressComRestApi { + + func processError(response httpResponse: HTTPURLResponse, body data: Data, additionalUserInfo: [String: Any]?) -> WordPressComRestApiEndpointError? { + // Not sure if it's intentional to include 500 status code, but the code seems to be there from the very beginning. + // https://github.com/wordpress-mobile/WordPressKit-iOS/blob/1.0.1/WordPressKit/WordPressComRestApi.swift#L374 + guard (400...500).contains(httpResponse.statusCode) else { + return nil + } + + guard let responseObject = try? JSONSerialization.jsonObject(with: data, options: .allowFragments), + let responseDictionary = responseObject as? [String: AnyObject] else { + + if let error = checkForThrottleErrorIn(response: httpResponse, data: data) { + return error + } + return .init(code: .unknown, response: httpResponse) + } + + // FIXME: A hack to support free WPCom sites and Rewind. Should be obsolote as soon as the backend + // stops returning 412's for those sites. + if httpResponse.statusCode == 412, let code = responseDictionary["code"] as? String, code == "no_connected_jetpack" { + return .init(code: .preconditionFailure, response: httpResponse) + } + + var errorDictionary: AnyObject? = responseDictionary as AnyObject? + if let errorArray = responseDictionary["errors"] as? [AnyObject], errorArray.count > 0 { + errorDictionary = errorArray.first + } + guard let errorEntry = errorDictionary as? [String: AnyObject], + let errorCode = errorEntry["error"] as? String, + let errorDescription = errorEntry["message"] as? String + else { + return .init(code: .unknown, response: httpResponse) + } + + let errorsMap: [String: WordPressComRestApiErrorCode] = [ + "invalid_input": .invalidInput, + "invalid_token": .invalidToken, + "authorization_required": .authorizationRequired, + "upload_error": .uploadFailed, + "unauthorized": .authorizationRequired, + "invalid_query": .invalidQuery, + "reauthorization_required": .reauthorizationRequired, + ] + + let mappedError = errorsMap[errorCode] ?? .unknown + if mappedError == .invalidToken || mappedError == .reauthorizationRequired { + // Call `invalidTokenHandler in the main thread since it's typically used by the apps to present an authentication UI. + DispatchQueue.main.async { + self.invalidTokenHandler?() + } + } + + var originalErrorUserInfo = additionalUserInfo ?? [:] + originalErrorUserInfo.removeValue(forKey: NSLocalizedDescriptionKey) + + return .init( + code: mappedError, + apiErrorCode: errorCode, + apiErrorMessage: errorDescription, + apiErrorData: errorEntry["data"], + additionalUserInfo: originalErrorUserInfo + ) + } + + func checkForThrottleErrorIn(response: HTTPURLResponse, data: Data) -> WordPressComRestApiEndpointError? { + // This endpoint is throttled, so check if we've sent too many requests and fill that error in as + // when too many requests occur the API just spits out an html page. + guard let responseString = String(data: data, encoding: .utf8), + responseString.contains("Limit reached") else { + return nil + } + + let message = NSLocalizedString( + "wordpresskit.api.message.endpoint_throttled", + value: "Limit reached. You can try again in 1 minute. Trying again before that will only increase the time you have to wait before the ban is lifted. If you think this is in error, contact support.", + comment: "Message to show when a request for a WP.com API endpoint is throttled" + ) + return .init( + code: .tooManyRequests, + apiErrorCode: "too_many_requests", + apiErrorMessage: message + ) + } +} +// MARK: - Anonymous API support + +extension WordPressComRestApi { + + /// Returns an API object without an OAuth token defined & with the userAgent set for the WordPress App user agent + /// + @objc class public func anonymousApi(userAgent: String) -> WordPressComRestApi { + return WordPressComRestApi(oAuthToken: nil, userAgent: userAgent) + } + + /// Returns an API object without an OAuth token defined & with both the userAgent & localeKey set for the WordPress App user agent + /// + @objc class public func anonymousApi(userAgent: String, localeKey: String) -> WordPressComRestApi { + return WordPressComRestApi(oAuthToken: nil, userAgent: userAgent, localeKey: localeKey) + } +} + +// MARK: - Constants + +private extension WordPressComRestApi { + + enum Constants { + static let buildRequestError = NSError(domain: WordPressComRestApiEndpointError.errorDomain, + code: WordPressComRestApiErrorCode.requestSerializationFailed.rawValue, + userInfo: [NSLocalizedDescriptionKey: NSLocalizedString("Failed to serialize request to the REST API.", + comment: "Error message to show when wrong URL format is used to access the REST API")]) + } +} + +// MARK: - POST encoding + +extension WordPressAPIError { + func asNSError() -> NSError { + // When encoutering `URLError`, return `URLError` to avoid potentially breaking existing error handling code in the apps. + if case let .connection(urlError) = self { + return urlError as NSError + } + + return self as NSError + } +} + +extension WordPressComRestApi: WordPressComRESTAPIInterfacing { + // A note on the naming: Even if defined as `GET` in Objective-C, then method gets converted to Swift as `get`. + // + // Also, there is no Objective-C direct equivalent of `AnyObject`, which here is used in `parameters: [String: AnyObject]?`. + // + // For those reasons, we can't immediately conform to `WordPressComRESTAPIInterfacing` and need instead to use this kind of wrapping. + // The same applies for the other methods below. + public func get( + _ URLString: String, + parameters: [String: Any]?, + success: @escaping (Any, HTTPURLResponse?) -> Void, + failure: @escaping (any Error, HTTPURLResponse?) -> Void + ) -> Progress? { + GET( + URLString, + // It's possible `WordPressComRestApi` could be updated to use `[String: Any]` instead. + // But leaving that investigation for later. + parameters: parameters as? [String: AnyObject], + success: success, + failure: failure + ) + } + + public func post( + _ URLString: String, + parameters: [String: Any]?, + success: @escaping (Any, HTTPURLResponse?) -> Void, + failure: @escaping (any Error, HTTPURLResponse?) -> Void + ) -> Progress? { + POST( + URLString, + // It's possible `WordPressComRestApi` could be updated to use `[String: Any]` instead. + // But leaving that investigation for later. + parameters: parameters as? [String: AnyObject], + success: success, + failure: failure + ) + } + + public func multipartPOST( + _ URLString: String, + parameters: [String: NSObject]?, + fileParts: [FilePart], + // Notice this does not require @escaping because it is Optional. + // + // Annotate with @escaping, and the compiler will fail with: + // > Closure is already escaping in optional type argument + // + // It is necessary to explicitly set this as Optional because of the _Nullable parameter requirement in the Objective-C protocol. + requestEnqueued: ((NSNumber) -> Void)?, + success: @escaping (Any, HTTPURLResponse?) -> Void, + failure: @escaping (any Error, HTTPURLResponse?) -> Void + ) -> Progress? { + multipartPOST( + URLString, + parameters: parameters, + fileParts: fileParts, + requestEnqueued: requestEnqueued, + success: success as SuccessResponseBlock, + failure: failure as FailureReponseBlock + ) + } + + @objc public func unknownResponseError() -> any Error { + NSError( + domain: WordPressComRestApiEndpointError.errorDomain, + code: WordPressComRestApiErrorCode.unknown.rawValue, + userInfo: [ + WordPressComRestApi.ErrorKeyErrorMessage: NSLocalizedString("Unknown error", comment: "Unknown error"), + NSLocalizedDescriptionKey: NSLocalizedString("Unknown error", comment: "Unknown error") + ] + ) + } + + @objc public func uploadFailedErrorCode() -> Int { + WordPressComRestApiErrorCode.uploadFailed.rawValue + } + + @objc public func errorCodeKey() -> String { + Self.ErrorKeyErrorCode + } + + @objc public func errorMessageKey() -> String { + Self.ErrorKeyErrorMessage + } +} diff --git a/Modules/Sources/WordPressKit/WordPressComServiceRemote+SiteCreation.swift b/Modules/Sources/WordPressKit/WordPressComServiceRemote+SiteCreation.swift new file mode 100644 index 000000000000..ef9dc01f01b7 --- /dev/null +++ b/Modules/Sources/WordPressKit/WordPressComServiceRemote+SiteCreation.swift @@ -0,0 +1,259 @@ +import Foundation + +// MARK: - SiteCreationRequest + +/// This value type is intended to express a site creation request. +/// +public struct SiteCreationRequest: Encodable { + public let segmentIdentifier: Int64? + public let verticalIdentifier: String? + public let title: String + public let tagline: String? + public let siteURLString: String + public let isPublic: Bool + public let languageIdentifier: String + public let shouldValidate: Bool + public let clientIdentifier: String + public let clientSecret: String + public let siteDesign: String? + public let timezoneIdentifier: String? + public let siteCreationFlow: String? + public let findAvailableURL: Bool + + public init(segmentIdentifier: Int64?, + siteDesign: String?, + verticalIdentifier: String?, + title: String, + tagline: String?, + siteURLString: String, + isPublic: Bool, + languageIdentifier: String, + shouldValidate: Bool, + clientIdentifier: String, + clientSecret: String, + timezoneIdentifier: String?, + siteCreationFlow: String?, + findAvailableURL: Bool) { + + self.segmentIdentifier = segmentIdentifier + self.siteDesign = siteDesign + self.verticalIdentifier = verticalIdentifier + self.title = title + self.tagline = tagline + self.siteURLString = siteURLString + self.isPublic = isPublic + self.languageIdentifier = languageIdentifier + self.shouldValidate = shouldValidate + self.clientIdentifier = clientIdentifier + self.clientSecret = clientSecret + self.timezoneIdentifier = timezoneIdentifier + self.siteCreationFlow = siteCreationFlow + self.findAvailableURL = findAvailableURL + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + try container.encode(clientIdentifier, forKey: .clientIdentifier) + try container.encode(clientSecret, forKey: .clientSecret) + try container.encode(languageIdentifier, forKey: .languageIdentifier) + try container.encode(shouldValidate, forKey: .shouldValidate) + try container.encode(siteURLString, forKey: .siteURLString) + try container.encode(title, forKey: .title) + try container.encode(findAvailableURL, forKey: .findAvailableURL) + + let publicValue = isPublic ? 1 : 0 + try container.encode(publicValue, forKey: .isPublic) + + let siteInfo: SiteInformation? + if let tagline { + siteInfo = SiteInformation(tagline: tagline) + } else { + siteInfo = nil + } + let options = SiteCreationOptions(segmentIdentifier: segmentIdentifier, + verticalIdentifier: verticalIdentifier, + siteInformation: siteInfo, + siteDesign: siteDesign, + timezoneIdentifier: timezoneIdentifier, + siteCreationFlow: siteCreationFlow) + + try container.encode(options, forKey: .options) + } + + private enum CodingKeys: String, CodingKey { + case clientIdentifier = "client_id" + case clientSecret = "client_secret" + case languageIdentifier = "lang_id" + case isPublic = "public" + case shouldValidate = "validate" + case siteURLString = "blog_name" + case title = "blog_title" + case options = "options" + case findAvailableURL = "find_available_url" + } +} + +private struct SiteCreationOptions: Encodable { + let segmentIdentifier: Int64? + let verticalIdentifier: String? + let siteInformation: SiteInformation? + let siteDesign: String? + let timezoneIdentifier: String? + let siteCreationFlow: String? + + enum CodingKeys: String, CodingKey { + case segmentIdentifier = "site_segment" + case verticalIdentifier = "site_vertical" + case siteInformation = "site_information" + case siteDesign = "template" + case timezoneIdentifier = "timezone_string" + case siteCreationFlow = "site_creation_flow" + } +} + +private struct SiteInformation: Encodable { + let tagline: String? + + enum CodingKeys: String, CodingKey { + case tagline = "site_tagline" + } +} + +// MARK: - SiteCreationResponse + +/// This value type is intended to express a site creation response. +/// +public struct SiteCreationResponse: Decodable { + public let createdSite: CreatedSite + public let success: Bool + + enum CodingKeys: String, CodingKey { + case createdSite = "blog_details" + case success + } +} + +/// This value type describes the site that was created. +/// +public struct CreatedSite: Decodable { + public let identifier: String + public let title: String + public let urlString: String + public let xmlrpcString: String + + enum CodingKeys: String, CodingKey { + case identifier = "blogid" + case title = "blogname" + case urlString = "url" + case xmlrpcString = "xmlrpc" + } +} + +// MARK: - WordPressComServiceRemote (Site Creation) + +/// Describes the errors that could arise during the process of site creation. +/// +/// - requestEncodingFailure: unable to encode the request parameters. +/// - responseDecodingFailure: unable to decode the server response. +/// - serviceFailure: the service returned an unexpected error. +/// +public enum SiteCreationError: Error { + case requestEncodingFailure + case responseDecodingFailure + case serviceFailure +} + +/// Advises the caller of results related to site creation requests. +/// +/// - success: the site creation request succeeded with the accompanying result. +/// - failure: the site creation request failed due to the accompanying error. +/// +@frozen public enum SiteCreationResult { + case success(SiteCreationResponse) + case failure(SiteCreationError) +} + +public typealias SiteCreationResultHandler = ((SiteCreationResult) -> Void) + +/// Site creation services, exclusive to WordPress.com. +/// +public extension WordPressComServiceRemote { + + /// Initiates a request to create a new WPCOM site. + /// + /// - Parameters: + /// - request: the value object with which to compose the request. + /// - completion: a closure including the result of the site creation attempt. + /// + func createWPComSite(request: SiteCreationRequest, completion: @escaping SiteCreationResultHandler) { + + let endpoint = "sites/new" + let path = self.path(forEndpoint: endpoint, withVersion: ._1_1) + + let requestParameters: [String: AnyObject] + do { + requestParameters = try encodeRequestParameters(request: request) + } catch { + WPKitLogError("Failed to encode \(SiteCreationRequest.self) : \(error)") + + completion(.failure(SiteCreationError.requestEncodingFailure)) + return + } + + wordPressComRESTAPI.post( + path, + parameters: requestParameters, + success: { [weak self] responseObject, httpResponse in + WPKitLogInfo("\(responseObject) | \(String(describing: httpResponse))") + + guard let self else { + return + } + + do { + let response = try self.decodeResponse(responseObject: responseObject) + completion(.success(response)) + } catch { + WPKitLogError("Failed to decode \(SiteCreationResponse.self) : \(error.localizedDescription)") + completion(.failure(SiteCreationError.responseDecodingFailure)) + } + }, + failure: { error, httpResponse in + WPKitLogError("\(error) | \(String(describing: httpResponse))") + completion(.failure(SiteCreationError.serviceFailure)) + }) + } +} + +// MARK: - Serialization support + +private extension WordPressComServiceRemote { + + func encodeRequestParameters(request: SiteCreationRequest) throws -> [String: AnyObject] { + + let encoder = JSONEncoder() + + let jsonData = try encoder.encode(request) + let serializedJSON = try JSONSerialization.jsonObject(with: jsonData, options: []) + + let requestParameters: [String: AnyObject] + if let jsonDictionary = serializedJSON as? [String: AnyObject] { + requestParameters = jsonDictionary + } else { + requestParameters = [:] + } + + return requestParameters + } + + func decodeResponse(responseObject: Any) throws -> SiteCreationResponse { + + let decoder = JSONDecoder() + + let data = try JSONSerialization.data(withJSONObject: responseObject, options: []) + let response = try decoder.decode(SiteCreationResponse.self, from: data) + + return response + } +} diff --git a/Modules/Sources/WordPressKit/WordPressComServiceRemote+SiteSegments.swift b/Modules/Sources/WordPressKit/WordPressComServiceRemote+SiteSegments.swift new file mode 100644 index 000000000000..045b50555eaa --- /dev/null +++ b/Modules/Sources/WordPressKit/WordPressComServiceRemote+SiteSegments.swift @@ -0,0 +1,139 @@ +import Foundation +/// Models a type of site. +public struct SiteSegment { + public let identifier: Int64 // we use a numeric ID for segments; see p9wMUP-bH-612-p2 for discussion + public let title: String + public let subtitle: String + public let icon: URL? + public let iconColor: String? + public let mobile: Bool + + public init(identifier: Int64, title: String, subtitle: String, icon: URL?, iconColor: String?, mobile: Bool) { + self.identifier = identifier + self.title = title + self.subtitle = subtitle + self.icon = icon + self.iconColor = iconColor + self.mobile = mobile + } +} + +extension SiteSegment: Equatable { + public static func ==(lhs: SiteSegment, rhs: SiteSegment) -> Bool { + return lhs.identifier == rhs.identifier + } +} + +extension SiteSegment: Decodable { + enum CodingKeys: String, CodingKey { + case segmentId = "id" + case segmentTypeTitle = "segment_type_title" + case segmentTypeSubtitle = "segment_type_subtitle" + case iconURL = "icon_URL" + case iconColor = "icon_color" + case mobile = "mobile" + } + + public init(from decoder: Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + identifier = try values.decode(Int64.self, forKey: .segmentId) + title = try values.decode(String.self, forKey: .segmentTypeTitle) + subtitle = try values.decode(String.self, forKey: .segmentTypeSubtitle) + if let iconString = try values.decodeIfPresent(String.self, forKey: .iconURL) { + icon = URL(string: iconString) + } else { + icon = nil + } + + if let iconColorString = try values.decodeIfPresent(String.self, forKey: .iconColor) { + var cleanIconColorString = iconColorString + if iconColorString.hasPrefix("#") { + cleanIconColorString = String(iconColorString.dropFirst(1)) + } + + iconColor = cleanIconColorString + } else { + iconColor = nil + } + + mobile = try values.decode(Bool.self, forKey: .mobile) + + } +} + +// MARK: - WordPressComServiceRemote (Site Segments) + +/// Describes the errors that could arise when searching for site verticals. +/// +/// - requestEncodingFailure: unable to encode the request parameters. +/// - responseDecodingFailure: unable to decode the server response. +/// - serviceFailure: the service returned an unexpected error. +/// +public enum SiteSegmentsError: Error { + case requestEncodingFailure + case responseDecodingFailure + case serviceFailure +} + +/// Advises the caller of results related to requests for site segments. +/// +/// - success: the site segments request succeeded with the accompanying result. +/// - failure: the site segments request failed due to the accompanying error. +/// +@frozen public enum SiteSegmentsResult { + case success([SiteSegment]) + case failure(SiteSegmentsError) +} + +public typealias SiteSegmentsServiceCompletion = (SiteSegmentsResult) -> Void + +/// Site segments service, exclusive to WordPress.com. +/// +public extension WordPressComServiceRemote { + func retrieveSegments(completion: @escaping SiteSegmentsServiceCompletion) { + let endpoint = "segments" + let remotePath = path(forEndpoint: endpoint, withVersion: ._2_0) + + wordPressComRESTAPI.get( + remotePath, + parameters: nil, + success: { [weak self] responseObject, httpResponse in + WPKitLogInfo("\(responseObject) | \(String(describing: httpResponse))") + + guard let self else { + return + } + + do { + let response = try self.decodeResponse(responseObject: responseObject) + let validContent = self.validSegments(response) + completion(.success(validContent)) + } catch { + WPKitLogError("Failed to decode \([SiteVertical].self) : \(error.localizedDescription)") + completion(.failure(SiteSegmentsError.responseDecodingFailure)) + } + }, + failure: { error, httpResponse in + WPKitLogError("\(error) | \(String(describing: httpResponse))") + completion(.failure(SiteSegmentsError.serviceFailure)) + }) + } +} + +// MARK: - Serialization support + +private extension WordPressComServiceRemote { + private func decodeResponse(responseObject: Any) throws -> [SiteSegment] { + let decoder = JSONDecoder() + let data = try JSONSerialization.data(withJSONObject: responseObject, options: []) + let response = try decoder.decode([SiteSegment].self, from: data) + + return response + } + + private func validSegments(_ allSegments: [SiteSegment]) -> [SiteSegment] { + return allSegments.filter { + return $0.mobile == true + } + } +} diff --git a/Modules/Sources/WordPressKit/WordPressComServiceRemote+SiteVerticals.swift b/Modules/Sources/WordPressKit/WordPressComServiceRemote+SiteVerticals.swift new file mode 100644 index 000000000000..778c56b41072 --- /dev/null +++ b/Modules/Sources/WordPressKit/WordPressComServiceRemote+SiteVerticals.swift @@ -0,0 +1,152 @@ +import Foundation + +// MARK: - SiteVerticalsRequest + +/// Allows the construction of a request for site verticals. +/// +/// NB: The default limit (5) applies to the number of results returned by the service. If a search with limit n evinces no exact match, (n - 1) server-unique results are returned. +/// +public struct SiteVerticalsRequest: Encodable { + public let search: String + public let limit: Int + + public init(search: String, limit: Int = 5) { + self.search = search + self.limit = limit + } +} + +// MARK: - SiteVertical(s) : Response + +/// Models a Site Vertical +/// +public struct SiteVertical: Decodable, Equatable { + public let identifier: String // vertical IDs mix parent/child taxonomy (String) + public let title: String + public let isNew: Bool + + public init(identifier: String, + title: String, + isNew: Bool) { + + self.identifier = identifier + self.title = title + self.isNew = isNew + } + + private enum CodingKeys: String, CodingKey { + // swiftlint:disable operator_usage_whitespace + case identifier = "vertical_id" + case title = "vertical_name" + case isNew = "is_user_input_vertical" + // swiftlint:enable operator_usage_whitespace + } +} + +// MARK: - WordPressComServiceRemote (Site Verticals) + +/// Describes the errors that could arise when searching for site verticals. +/// +/// - requestEncodingFailure: unable to encode the request parameters. +/// - responseDecodingFailure: unable to decode the server response. +/// - serviceFailure: the service returned an unexpected error. +/// +public enum SiteVerticalsError: Error { + case requestEncodingFailure + case responseDecodingFailure + case serviceFailure +} + +/// Advises the caller of results related to requests for site verticals. +/// +/// - success: the site verticals request succeeded with the accompanying result. +/// - failure: the site verticals request failed due to the accompanying error. +/// +public enum SiteVerticalsResult { + case success([SiteVertical]) + case failure(SiteVerticalsError) +} + +public typealias SiteVerticalsServiceCompletion = ((SiteVerticalsResult) -> Void) + +/// Site verticals services, exclusive to WordPress.com. +/// +public extension WordPressComServiceRemote { + + /// Retrieves Verticals matching the specified criteria. + /// + /// - Parameters: + /// - request: the value object with which to compose the request. + /// - completion: a closure including the result of the request for site verticals. + /// + func retrieveVerticals(request: SiteVerticalsRequest, completion: @escaping SiteVerticalsServiceCompletion) { + + let endpoint = "verticals" + let path = self.path(forEndpoint: endpoint, withVersion: ._2_0) + + let requestParameters: [String: AnyObject] + do { + requestParameters = try encodeRequestParameters(request: request) + } catch { + WPKitLogError("Failed to encode \(SiteCreationRequest.self) : \(error)") + + completion(.failure(SiteVerticalsError.requestEncodingFailure)) + return + } + + wordPressComRESTAPI.get( + path, + parameters: requestParameters, + success: { [weak self] responseObject, httpResponse in + WPKitLogInfo("\(responseObject) | \(String(describing: httpResponse))") + + guard let self else { + return + } + + do { + let response = try self.decodeResponse(responseObject: responseObject) + completion(.success(response)) + } catch { + WPKitLogError("Failed to decode \([SiteVertical].self) : \(error.localizedDescription)") + completion(.failure(SiteVerticalsError.responseDecodingFailure)) + } + }, + failure: { error, httpResponse in + WPKitLogError("\(error) | \(String(describing: httpResponse))") + completion(.failure(SiteVerticalsError.serviceFailure)) + }) + } +} + +// MARK: - Serialization support + +private extension WordPressComServiceRemote { + + func encodeRequestParameters(request: SiteVerticalsRequest) throws -> [String: AnyObject] { + + let encoder = JSONEncoder() + + let jsonData = try encoder.encode(request) + let serializedJSON = try JSONSerialization.jsonObject(with: jsonData, options: []) + + let requestParameters: [String: AnyObject] + if let jsonDictionary = serializedJSON as? [String: AnyObject] { + requestParameters = jsonDictionary + } else { + requestParameters = [:] + } + + return requestParameters + } + + func decodeResponse(responseObject: Any) throws -> [SiteVertical] { + + let decoder = JSONDecoder() + + let data = try JSONSerialization.data(withJSONObject: responseObject, options: []) + let response = try decoder.decode([SiteVertical].self, from: data) + + return response + } +} diff --git a/Modules/Sources/WordPressKit/WordPressComServiceRemote+SiteVerticalsPrompt.swift b/Modules/Sources/WordPressKit/WordPressComServiceRemote+SiteVerticalsPrompt.swift new file mode 100644 index 000000000000..146efa2d55a0 --- /dev/null +++ b/Modules/Sources/WordPressKit/WordPressComServiceRemote+SiteVerticalsPrompt.swift @@ -0,0 +1,88 @@ +import Foundation + +// MARK: - Site Verticals Prompt : Request + +public typealias SiteVerticalsPromptRequest = Int64 + +// MARK: - Site Verticals Prompt : Response + +public struct SiteVerticalsPrompt: Decodable { + public let title: String + public let subtitle: String + public let hint: String + + public init(title: String, subtitle: String, hint: String) { + self.title = title + self.subtitle = subtitle + self.hint = hint + } + + private enum CodingKeys: String, CodingKey { + // swiftlint:disable operator_usage_whitespace + case title = "site_topic_header" + case subtitle = "site_topic_subheader" + case hint = "site_topic_placeholder" + // swiftlint:enable operator_usage_whitespace + } +} + +public typealias SiteVerticalsPromptServiceCompletion = ((SiteVerticalsPrompt?) -> Void) + +/// Site verticals services, exclusive to WordPress.com. +/// +public extension WordPressComServiceRemote { + + /// Retrieves the prompt information presented to users when searching Verticals. + /// + /// - Parameters: + /// - request: the value object with which to compose the request. + /// - completion: a closure including the result of the request for site verticals. + /// + func retrieveVerticalsPrompt(request: SiteVerticalsPromptRequest, completion: @escaping SiteVerticalsPromptServiceCompletion) { + + let endpoint = "verticals/prompt" + let path = self.path(forEndpoint: endpoint, withVersion: ._2_0) + + let requestParameters: [String: AnyObject] = [ + "segment_id": request as AnyObject + ] + + wordPressComRESTAPI.get( + path, + parameters: requestParameters, + success: { [weak self] responseObject, httpResponse in + WPKitLogInfo("\(responseObject) | \(String(describing: httpResponse))") + + guard let self else { + return + } + + do { + let response = try self.decodeResponse(responseObject: responseObject) + completion(response) + } catch { + WPKitLogError("Failed to decode SiteVerticalsPrompt : \(error.localizedDescription)") + completion(nil) + } + }, + failure: { error, httpResponse in + WPKitLogError("\(error) | \(String(describing: httpResponse))") + completion(nil) + }) + } +} + +// MARK: - Serialization support +// +private extension WordPressComServiceRemote { + + func decodeResponse(responseObject: Any) throws -> SiteVerticalsPrompt { + + let decoder = JSONDecoder() + + let data = try JSONSerialization.data(withJSONObject: responseObject, options: []) + let response = try decoder.decode(SiteVerticalsPrompt.self, from: data) + + return response + } +} diff --git a/Modules/Sources/WordPressKit/WordPressOrgRestApi.swift b/Modules/Sources/WordPressKit/WordPressOrgRestApi.swift new file mode 100644 index 000000000000..30adcb2d14fd --- /dev/null +++ b/Modules/Sources/WordPressKit/WordPressOrgRestApi.swift @@ -0,0 +1,268 @@ +import Foundation + +public struct WordPressOrgRestApiError: LocalizedError, Decodable, HTTPURLResponseProviding { + public enum CodingKeys: String, CodingKey { + case code, message + } + + public var code: String + public var message: String? + + var response: HTTPAPIResponse? + + var httpResponse: HTTPURLResponse? { + response?.response + } + + public var errorDescription: String? { + return message ?? NSLocalizedString( + "wordpresskit.org-rest-api.not-found", + value: "Couldn't find your site's REST API URL. The app needs that in order to communicate with your site. Contact your host to solve this problem.", + comment: "Message to show to user when the app can't find WordPress.org REST API URL." + ) + } +} + +@objc +public final class WordPressOrgRestApi: NSObject { + public struct SelfHostedSiteCredential { + public let loginURL: URL + public let username: String + public let password: Secret + public let adminURL: URL + + public init(loginURL: URL, username: String, password: String, adminURL: URL) { + self.loginURL = loginURL + self.username = username + self.password = .init(password) + self.adminURL = adminURL + } + } + + enum Site { + case dotCom(siteID: UInt64, bearerToken: String, apiURL: URL) + case selfHosted(apiURL: URL, credential: SelfHostedSiteCredential) + } + + let site: Site + let urlSession: URLSession + + var selfHostedSiteNonce: String? + + public convenience init(dotComSiteID: UInt64, bearerToken: String, userAgent: String? = nil, apiURL: URL = WordPressComRestApi.apiBaseURL) { + self.init(site: .dotCom(siteID: dotComSiteID, bearerToken: bearerToken, apiURL: apiURL), userAgent: userAgent) + } + + public convenience init(selfHostedSiteWPJSONURL apiURL: URL, credential: SelfHostedSiteCredential, userAgent: String? = nil) { + assert(apiURL.host != "public-api.wordpress.com", "Not a self-hosted site: \(apiURL)") + // Potential improvement(?): discover API URL instead. See https://developer.wordpress.org/rest-api/using-the-rest-api/discovery/ + assert(apiURL.lastPathComponent == "wp-json", "Not a REST API URL: \(apiURL)") + + self.init(site: .selfHosted(apiURL: apiURL, credential: credential), userAgent: userAgent) + } + + init(site: Site, userAgent: String? = nil) { + self.site = site + + var additionalHeaders = [String: String]() + if let userAgent { + additionalHeaders["User-Agent"] = userAgent + } + if case let Site.dotCom(_, token, _) = site { + additionalHeaders["Authorization"] = "Bearer \(token)" + } + + let configuration = URLSessionConfiguration.default + configuration.httpAdditionalHeaders = additionalHeaders + urlSession = URLSession(configuration: configuration) + } + + deinit { + urlSession.finishTasksAndInvalidate() + } + + @objc + public func invalidateAndCancelTasks() { + urlSession.invalidateAndCancel() + } + + public func get( + path: String, + parameters: [String: Any]? = nil, + jsonDecoder: JSONDecoder = JSONDecoder(), + type: Success.Type = Success.self + ) async -> WordPressAPIResult { + await perform(.get, path: path, parameters: parameters, jsonDecoder: jsonDecoder, type: type) + } + + public func get( + path: String, + parameters: [String: Any]? = nil, + options: JSONSerialization.ReadingOptions = [] + ) async -> WordPressAPIResult { + await perform(.get, path: path, parameters: parameters, options: options) + } + + public func post( + path: String, + parameters: [String: Any]? = nil, + jsonDecoder: JSONDecoder = JSONDecoder(), + type: Success.Type = Success.self + ) async -> WordPressAPIResult { + await perform(.post, path: path, parameters: parameters, jsonDecoder: jsonDecoder, type: type) + } + + public func post( + path: String, + parameters: [String: Any]? = nil, + options: JSONSerialization.ReadingOptions = [] + ) async -> WordPressAPIResult { + await perform(.post, path: path, parameters: parameters, options: options) + } + + func perform( + _ method: HTTPRequestBuilder.Method, + path: String, + parameters: [String: Any]? = nil, + jsonDecoder: JSONDecoder = JSONDecoder(), + type: Success.Type = Success.self + ) async -> WordPressAPIResult { + await perform(method, path: path, parameters: parameters) { + try jsonDecoder.decode(type, from: $0) + } + } + + func perform( + _ method: HTTPRequestBuilder.Method, + path: String, + parameters: [String: Any]? = nil, + options: JSONSerialization.ReadingOptions = [] + ) async -> WordPressAPIResult { + await perform(method, path: path, parameters: parameters) { + try JSONSerialization.jsonObject(with: $0, options: options) + } + } + + private func perform( + _ method: HTTPRequestBuilder.Method, + path: String, + parameters: [String: Any]? = nil, + decoder: @escaping (Data) throws -> Success + ) async -> WordPressAPIResult { + var builder = HTTPRequestBuilder(url: apiBaseURL()) + .dotOrgRESTAPI(route: path, site: site) + .method(method) + if method.allowsHTTPBody { + builder = builder.body(form: parameters ?? [:]) + } else { + builder = builder.query(parameters ?? [:]) + } + + return await perform(builder: builder) + .mapSuccess { try decoder($0.body) } + } + + func perform(builder originalBuilder: HTTPRequestBuilder) async -> WordPressAPIResult, WordPressOrgRestApiError> { + var builder = originalBuilder + + if case .selfHosted = site, let nonce = selfHostedSiteNonce { + builder = originalBuilder.header(name: "X-WP-Nonce", value: nonce) + } + + var result = await urlSession.perform(request: builder, errorType: WordPressOrgRestApiError.self) + + // When a self hosted site request fails with 401, authenticate and retry the request. + if case .selfHosted = site, + case let .failure(.unacceptableStatusCode(response, _)) = result, + response.statusCode == 401, + await refreshNonce(), + let nonce = selfHostedSiteNonce { + builder = originalBuilder.header(name: "X-WP-Nonce", value: nonce) + result = await urlSession.perform(request: builder, errorType: WordPressOrgRestApiError.self) + } + + return result + .mapError { error in + if case let .unacceptableStatusCode(response, body) = error { + do { + var endpointError = try JSONDecoder().decode(WordPressOrgRestApiError.self, from: body) + endpointError.response = HTTPAPIResponse(response: response, body: body) + return WordPressAPIError.endpointError(endpointError) + } catch { + return .unparsableResponse(response: response, body: body, underlyingError: error) + } + } + return error + } + } + +} + +// MARK: - Authentication + +private extension WordPressOrgRestApi { + func apiBaseURL() -> URL { + switch site { + case let .dotCom(_, _, apiURL): + return apiURL + case let .selfHosted(apiURL, _): + return apiURL + } + } + + /// Fetch REST API nonce from the site. + /// + /// - Returns true if the nonce is fetched and it's different than the cached one. + func refreshNonce() async -> Bool { + guard case let .selfHosted(_, credential) = site else { + return false + } + + var refreshed = false + + let methods: [NonceRetrievalMethod] = [.ajaxNonceRequest, .newPostScrap] + for method in methods { + guard let nonce = await method.retrieveNonce( + username: credential.username, + password: credential.password, + loginURL: credential.loginURL, + adminURL: credential.adminURL, + using: urlSession + ) else { + continue + } + + refreshed = selfHostedSiteNonce != nonce + + selfHostedSiteNonce = nonce + break + } + + return refreshed + } +} + +// MARK: - Helpers + +private extension HTTPRequestBuilder { + func dotOrgRESTAPI(route aRoute: String, site: WordPressOrgRestApi.Site) -> Self { + var route = aRoute + if !route.hasPrefix("/") { + route = "/" + route + } + + switch site { + case let .dotCom(siteID, _, _): + // Currently only the following namespaces are supported. When adding more supported namespaces, remember to + // update the "path adapter" code below for the REST API in WP.COM. + assert(route.hasPrefix("/wp/v2") || route.hasPrefix("/wp-block-editor/v1"), "Unsupported .org REST API route: \(route)") + route = route + .replacingOccurrences(of: "/wp/v2/", with: "/wp/v2/sites/\(siteID)/") + .replacingOccurrences(of: "/wp-block-editor/v1/", with: "/wp-block-editor/v1/sites/\(siteID)/") + case let .selfHosted(apiURL, _): + assert(apiURL.lastPathComponent == "wp-json") + } + + return appendURLString(route) + } +} diff --git a/Modules/Sources/WordPressKit/WordPressOrgXMLRPCApi.swift b/Modules/Sources/WordPressKit/WordPressOrgXMLRPCApi.swift new file mode 100644 index 000000000000..b57c1fc73ae6 --- /dev/null +++ b/Modules/Sources/WordPressKit/WordPressOrgXMLRPCApi.swift @@ -0,0 +1,439 @@ +import Foundation +import wpxmlrpc + +/// Class to connect to the XMLRPC API on self hosted sites. +open class WordPressOrgXMLRPCApi: NSObject, WordPressOrgXMLRPCApiInterfacing { + public typealias SuccessResponseBlock = (Any, HTTPURLResponse?) -> Void + public typealias FailureReponseBlock = (_ error: any Error, _ httpResponse: HTTPURLResponse?) -> Void + + @available(*, deprecated, message: "This property is no longer being used because WordPressKit now sends all HTTP requests using `URLSession` directly.") + public static var useURLSession = true + + private let endpoint: URL + private let userAgent: String? + private var backgroundUploads: Bool + private var backgroundSessionIdentifier: String + @objc public static let defaultBackgroundSessionIdentifier = "org.wordpress.wporgxmlrpcapi" + + /// onChallenge's Callback Closure Signature. Host Apps should call this method, whenever a proper AuthChallengeDisposition has been + /// picked up (optionally with URLCredentials!). + /// + public typealias AuthenticationHandler = (URLSession.AuthChallengeDisposition, URLCredential?) -> Void + + /// Closure to be executed whenever we receive a URLSession Authentication Challenge. + /// + public static var onChallenge: ((URLAuthenticationChallenge, @escaping AuthenticationHandler) -> Void)? + + /// Minimum WordPress.org Supported Version. + /// + @objc public static let minimumSupportedVersion = "4.0" + + @objc public static var errorDomain: String { + wpxmlrpc.WPXMLRPCFaultErrorDomain + } + + private lazy var urlSession: URLSession = makeSession(configuration: .default) + private lazy var uploadURLSession: URLSession = { + backgroundUploads + ? makeSession(configuration: .background(withIdentifier: self.backgroundSessionIdentifier)) + : urlSession + }() + + private func makeSession(configuration sessionConfiguration: URLSessionConfiguration) -> URLSession { + var additionalHeaders: [String: AnyObject] = ["Accept-Encoding": "gzip, deflate" as AnyObject] + if let userAgent = self.userAgent { + additionalHeaders["User-Agent"] = userAgent as AnyObject? + } + sessionConfiguration.httpAdditionalHeaders = additionalHeaders + // When using a background URLSession, we don't need to apply the authentication challenge related + // implementations in `SessionDelegate`. + if sessionConfiguration.identifier != nil { + return URLSession.backgroundSession(configuration: sessionConfiguration) + } else { + return URLSession(configuration: sessionConfiguration, delegate: sessionDelegate, delegateQueue: nil) + } + } + + // swiftlint:disable weak_delegate + /// `URLSessionDelegate` for the URLSession instances in this class. + private let sessionDelegate = SessionDelegate() + // swiftlint:enable weak_delegate + + /// Creates a new API object to connect to the WordPress XMLRPC API for the specified endpoint. + /// + /// - Parameters: + /// - endpoint: the endpoint to connect to the xmlrpc api interface. + /// - userAgent: the user agent to use on the connection. + /// - backgroundUploads: If this value is true the API object will use a background session to execute uploads requests when using the `multipartPOST` function. The default value is false. + /// - backgroundSessionIdentifier: The session identifier to use for the background session. This must be unique in the system. + @objc public init(endpoint: URL, userAgent: String? = nil, backgroundUploads: Bool = false, backgroundSessionIdentifier: String) { + self.endpoint = endpoint + self.userAgent = userAgent + self.backgroundUploads = backgroundUploads + self.backgroundSessionIdentifier = backgroundSessionIdentifier + super.init() + } + + /// Creates a new API object to connect to the WordPress XMLRPC API for the specified endpoint. The background uploads are disabled when using this initializer. + /// + /// - Parameters: + /// - endpoint: the endpoint to connect to the xmlrpc api interface. + /// - userAgent: the user agent to use on the connection. + @objc convenience public init(endpoint: URL, userAgent: String? = nil) { + self.init(endpoint: endpoint, userAgent: userAgent, backgroundUploads: false, backgroundSessionIdentifier: WordPressOrgXMLRPCApi.defaultBackgroundSessionIdentifier + "." + endpoint.absoluteString) + } + + deinit { + for session in [urlSession, uploadURLSession] { + session.finishTasksAndInvalidate() + } + } + + /** + Cancels all ongoing and makes the session so the object will not fullfil any more request + */ + @objc open func invalidateAndCancelTasks() { + for session in [urlSession, uploadURLSession] { + session.invalidateAndCancel() + } + } + + // MARK: - Network requests + /** + Check if username and password are valid credentials for the xmlrpc endpoint. + + - parameter username: username to check + - parameter password: password to check + - parameter success: callback block to be invoked if credentials are valid, the object returned in the block is the options dictionary for the site. + - parameter failure: callback block to be invoked is credentials fail + */ + @objc open func checkCredentials(_ username: String, + password: String, + success: @escaping SuccessResponseBlock, + failure: @escaping FailureReponseBlock) { + let parameters: [AnyObject] = [0 as AnyObject, username as AnyObject, password as AnyObject] + callMethod("wp.getOptions", parameters: parameters, success: success, failure: failure) + } + /** + Executes a XMLRPC call for the method specificied with the arguments provided. + + - parameter method: the xmlrpc method to be invoked + - parameter parameters: the parameters to be encoded on the request + - parameter success: callback to be called on successful request + - parameter failure: callback to be called on failed request + + - returns: a NSProgress object that can be used to track the progress of the request and to cancel the request. If the method + returns nil it's because something happened on the request serialization and the network request was not started, but the failure callback + will be invoked with the error specificing the serialization issues. + */ + @objc @discardableResult open func callMethod( + _ method: String, + parameters: [Any]?, + success: @escaping (Any, HTTPURLResponse?) -> Void, + failure: @escaping (any Error, HTTPURLResponse?) -> Void + ) -> Progress { + let progress = Progress.discreteProgress(totalUnitCount: 100) + Task { @MainActor in + let result = await self.call(method: method, parameters: parameters, fulfilling: progress, streaming: false) + switch result { + case let .success(response): + success(response.body, response.response) + case let .failure(error): + failure(error.asNSError(), error.response) + } + } + return progress + } + + /** + Executes a XMLRPC call for the method specificied with the arguments provided, by streaming the request from a file. + This allows to do requests that can use a lot of memory, like media uploads. + + - parameter method: the xmlrpc method to be invoked + - parameter parameters: the parameters to be encoded on the request + - parameter success: callback to be called on successful request + - parameter failure: callback to be called on failed request + + - returns: a NSProgress object that can be used to track the progress of the request and to cancel the request. If the method + returns nil it's because something happened on the request serialization and the network request was not started, but the failure callback + will be invoked with the error specificing the serialization issues. + */ + @objc @discardableResult open func streamCallMethod( + _ method: String, parameters: [Any]?, + success: @escaping (Any, HTTPURLResponse?) -> Void, + failure: @escaping (any Error, HTTPURLResponse?) -> Void + ) -> Progress { + let progress = Progress.discreteProgress(totalUnitCount: 100) + Task { @MainActor in + let result = await self.call(method: method, parameters: parameters, fulfilling: progress, streaming: true) + switch result { + case let .success(response): + success(response.body, response.response) + case let .failure(error): + failure(error.asNSError(), error.response) + } + } + return progress + } + + /// Call an XMLRPC method. + /// + /// ## Error handling + /// + /// Unlike the closure-based APIs, this method returns a concrete error type. You should consider handling the errors + /// as they are, instead of casting them to `NSError` instance. But in case you do need to cast them to `NSError`, + /// considering using the `asNSError` function if you need backward compatibility with existing code. + /// + /// - Parameters: + /// - streaming: set to `true` if there are large data (i.e. uploading files) in given `parameters`. `false` by default. + /// - Returns: A `Result` type that contains the XMLRPC success or failure result. + func call(method: String, parameters: [Any]?, fulfilling progress: Progress? = nil, streaming: Bool = false) async -> WordPressAPIResult, WordPressOrgXMLRPCApiFault> { + let session = streaming ? uploadURLSession : urlSession + let builder = HTTPRequestBuilder(url: endpoint) + .method(.post) + .body(xmlrpc: method, parameters: parameters) + return await session + .perform( + request: builder, + // All HTTP responses are treated as successful result. Error handling will be done in `decodeXMLRPCResult`. + acceptableStatusCodes: [1...999], + fulfilling: progress, + errorType: WordPressOrgXMLRPCApiFault.self + ) + .decodeXMLRPCResult() + } + + @objc public static let WordPressOrgXMLRPCApiErrorKeyData: NSError.UserInfoKey = "WordPressOrgXMLRPCApiErrorKeyData" + @objc public static let WordPressOrgXMLRPCApiErrorKeyDataString: NSError.UserInfoKey = "WordPressOrgXMLRPCApiErrorKeyDataString" + @objc public static let WordPressOrgXMLRPCApiErrorKeyStatusCode: NSError.UserInfoKey = "WordPressOrgXMLRPCApiErrorKeyStatusCode" + + fileprivate static func convertError(_ error: NSError, data: Data?, statusCode: Int? = nil) -> NSError { + let responseCode = statusCode == 403 ? 403 : error.code + if let data { + var userInfo: [String: Any] = error.userInfo + userInfo[Self.WordPressOrgXMLRPCApiErrorKeyData as String] = data + userInfo[Self.WordPressOrgXMLRPCApiErrorKeyDataString as String] = NSString(data: data, encoding: String.Encoding.utf8.rawValue) + userInfo[Self.WordPressOrgXMLRPCApiErrorKeyStatusCode as String] = statusCode + userInfo[NSLocalizedFailureErrorKey] = error.localizedDescription + + if let statusCode, (400..<600).contains(statusCode) { + let formatString = NSLocalizedString("An HTTP error code %i was returned.", comment: "A failure reason for when an error HTTP status code was returned from the site, with the specific error code.") + userInfo[NSLocalizedFailureReasonErrorKey] = String(format: formatString, statusCode) + } else { + userInfo[NSLocalizedFailureReasonErrorKey] = error.localizedFailureReason + } + + return NSError(domain: error.domain, code: responseCode, userInfo: userInfo) + } + return error + } +} + +private class SessionDelegate: NSObject, URLSessionDelegate { + + @objc func urlSession( + _ session: URLSession, + didReceive challenge: URLAuthenticationChallenge, + completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void + ) { + + switch challenge.protectionSpace.authenticationMethod { + case NSURLAuthenticationMethodServerTrust: + if let credential = URLCredentialStorage.shared.defaultCredential(for: challenge.protectionSpace), challenge.previousFailureCount == 0 { + completionHandler(.useCredential, credential) + return + } + + guard let serverTrust = challenge.protectionSpace.serverTrust else { + completionHandler(.performDefaultHandling, nil) + return + } + + _ = SecTrustEvaluateWithError(serverTrust, nil) + var result = SecTrustResultType.invalid + let certificateStatus = SecTrustGetTrustResult(serverTrust, &result) + + guard let hostAppHandler = WordPressOrgXMLRPCApi.onChallenge, certificateStatus == 0, result == .recoverableTrustFailure else { + completionHandler(.performDefaultHandling, nil) + return + } + + DispatchQueue.main.async { + hostAppHandler(challenge, completionHandler) + } + + default: + completionHandler(.performDefaultHandling, nil) + } + } + + @objc func urlSession( + _ session: URLSession, + task: URLSessionTask, + didReceive challenge: URLAuthenticationChallenge, + completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void + ) { + + switch challenge.protectionSpace.authenticationMethod { + case NSURLAuthenticationMethodHTTPBasic: + if let credential = URLCredentialStorage.shared.defaultCredential(for: challenge.protectionSpace), challenge.previousFailureCount == 0 { + completionHandler(.useCredential, credential) + return + } + + guard let hostAppHandler = WordPressOrgXMLRPCApi.onChallenge else { + completionHandler(.performDefaultHandling, nil) + return + } + + DispatchQueue.main.async { + hostAppHandler(challenge, completionHandler) + } + + default: + completionHandler(.performDefaultHandling, nil) + } + } +} + +/// Error constants for the WordPress XML-RPC API +@objc public enum WordPressOrgXMLRPCApiError: Int, Error { + /// An error HTTP status code was returned. + case httpErrorStatusCode + /// The serialization of the request failed. + case requestSerializationFailed + /// The serialization of the response failed. + case responseSerializationFailed + /// An unknown error occurred. + case unknown +} + +extension WordPressOrgXMLRPCApiError: LocalizedError { + public var errorDescription: String? { + return NSLocalizedString("There was a problem communicating with the site.", comment: "A general error message shown to the user when there was an API communication failure.") + } + + public var failureReason: String? { + switch self { + case .httpErrorStatusCode: + return NSLocalizedString("An HTTP error code was returned.", comment: "A failure reason for when an error HTTP status code was returned from the site.") + case .requestSerializationFailed: + return NSLocalizedString("The serialization of the request failed.", comment: "A failure reason for when the request couldn't be serialized.") + case .responseSerializationFailed: + return NSLocalizedString("The serialization of the response failed.", comment: "A failure reason for when the response couldn't be serialized.") + case .unknown: + return NSLocalizedString("An unknown error occurred.", comment: "A failure reason for when the error that occured wasn't able to be determined.") + } + } +} + +public struct WordPressOrgXMLRPCApiFault: LocalizedError, HTTPURLResponseProviding { + public var response: HTTPAPIResponse + public let code: Int? + public let message: String? + + public init(response: HTTPAPIResponse, code: Int?, message: String?) { + self.response = response + self.code = code + self.message = message + } + + public var errorDescription: String? { + message + } + + public var httpResponse: HTTPURLResponse? { + response.response + } +} + +private extension WordPressAPIResult, WordPressOrgXMLRPCApiFault> { + + func decodeXMLRPCResult() -> WordPressAPIResult, WordPressOrgXMLRPCApiFault> { + // This is a re-implementation of `WordPressOrgXMLRPCApi.handleResponseWithData` function: + // https://github.com/wordpress-mobile/WordPressKit-iOS/blob/11.0.0/WordPressKit/WordPressOrgXMLRPCApi.swift#L265 + flatMap { response in + guard let contentType = response.response.allHeaderFields["Content-Type"] as? String else { + return .failure(.unparsableResponse(response: response.response, body: response.body, underlyingError: WordPressOrgXMLRPCApiError.unknown)) + } + + if (400..<600).contains(response.response.statusCode) { + if let decoder = WPXMLRPCDecoder(data: response.body), decoder.isFault() { + // when XML-RPC is disabled for authenticated calls (e.g. xmlrpc_enabled is false on WP.org), + // it will return a valid fault payload with a non-200 + return .failure(.endpointError(.init(response: response, code: decoder.faultCode(), message: decoder.faultString()))) + } else { + return .failure(.unacceptableStatusCode(response: response.response, body: response.body)) + } + } + + guard contentType.hasPrefix("application/xml") || contentType.hasPrefix("text/xml") else { + return .failure(.unparsableResponse(response: response.response, body: response.body, underlyingError: WordPressOrgXMLRPCApiError.unknown)) + } + + guard let decoder = WPXMLRPCDecoder(data: response.body) else { + return .failure(.unparsableResponse(response: response.response, body: response.body)) + } + + guard !decoder.isFault() else { + return .failure(.endpointError(.init(response: response, code: decoder.faultCode(), message: decoder.faultString()))) + } + + if let decoderError = decoder.error() { + return .failure(.unparsableResponse(response: response.response, body: response.body, underlyingError: decoderError)) + } + + guard let responseXML = decoder.object() else { + return .failure(.unparsableResponse(response: response.response, body: response.body)) + } + + return .success(HTTPAPIResponse(response: response.response, body: responseXML as AnyObject)) + } + } + +} + +private extension WordPressAPIError where EndpointError == WordPressOrgXMLRPCApiFault { + + /// Convert to NSError for backwards compatiblity. + /// + /// Some Objective-C code in the WordPress app checks domain of the errors returned by `WordPressOrgXMLRPCApi`, + /// which can be WordPressOrgXMLRPCApiError or WPXMLRPCFaultErrorDomain. + /// + /// Swift code should avoid dealing with NSError instances. Instead, they should use the strongly typed + /// `WordPressAPIError`. + func asNSError() -> NSError { + let error: NSError + let data: Data? + let statusCode: Int? + switch self { + case let .requestEncodingFailure(underlyingError): + error = underlyingError as NSError + data = nil + statusCode = nil + case let .connection(urlError): + error = urlError as NSError + data = nil + statusCode = nil + case let .endpointError(fault): + error = NSError(domain: WPXMLRPCFaultErrorDomain, code: fault.code ?? 0, userInfo: [NSLocalizedDescriptionKey: fault.message].compactMapValues { $0 }) + data = fault.response.body + statusCode = nil + case let .unacceptableStatusCode(response, body): + error = WordPressOrgXMLRPCApiError.httpErrorStatusCode as NSError + data = body + statusCode = response.statusCode + case let .unparsableResponse(_, body, underlyingError): + error = underlyingError as NSError + data = body + statusCode = nil + case let .unknown(underlyingError): + error = underlyingError as NSError + data = nil + statusCode = nil + } + + return WordPressOrgXMLRPCApi.convertError(error, data: data, statusCode: statusCode) + } + +} diff --git a/Modules/Sources/WordPressKit/WordPressOrgXMLRPCValidator.swift b/Modules/Sources/WordPressKit/WordPressOrgXMLRPCValidator.swift new file mode 100644 index 000000000000..a54877fe4a23 --- /dev/null +++ b/Modules/Sources/WordPressKit/WordPressOrgXMLRPCValidator.swift @@ -0,0 +1,366 @@ +import Foundation + +@objc public enum WordPressOrgXMLRPCValidatorError: Int, Error { + case emptyURL // The URL provided was nil, empty or just whitespaces + case invalidURL // The URL provided was an invalid URL + case invalidScheme // The URL provided was an invalid scheme, only HTTP and HTTPS supported + case notWordPressError // That's a XML-RPC endpoint but doesn't look like WordPress + case mobilePluginRedirectedError // There's some "mobile" plugin redirecting everything to their site + case forbidden = 403 // Server returned a 403 error while reading xmlrpc file + case blocked = 405 // Server returned a 405 error while reading xmlrpc file + case invalid // Doesn't look to be valid XMLRPC Endpoint. + case xmlrpc_missing // site contains RSD link but XML-RPC information is missing + + public var localizedDescription: String { + switch self { + case .emptyURL: + return NSLocalizedString("Empty URL", comment: "Message to show to user when he tries to add a self-hosted site that is an empty URL.") + case .invalidURL: + return NSLocalizedString("Invalid URL, please check if you wrote a valid site address.", comment: "Message to show to user when he tries to add a self-hosted site that isn't a valid URL.") + case .invalidScheme: + return NSLocalizedString("Invalid URL scheme inserted, only HTTP and HTTPS are supported.", comment: "Message to show to user when he tries to add a self-hosted site that isn't HTTP or HTTPS.") + case .notWordPressError: + return NSLocalizedString("That doesn't look like a WordPress site.", comment: "Message to show to user when he tries to add a self-hosted site that isn't a WordPress site.") + case .mobilePluginRedirectedError: + return NSLocalizedString( + "You seem to have installed a mobile plugin from DudaMobile which is preventing the app to connect to your blog", + comment: "Error messaged show when a mobile plugin is redirecting traffict to their site, DudaMobile in particular" + ) + case .invalid: + return NSLocalizedString("Couldn't connect to the WordPress site. There is no valid WordPress site at this address. Check the site address (URL) you entered.", comment: "Error message shown a URL points to a valid site but not a WordPress site.") + case .blocked: + return NSLocalizedString("Couldn't connect. Your host is blocking POST requests, and the app needs that in order to communicate with your site. Please contact your hosting provider to solve this problem.", comment: "Message to show to user when he tries to add a self-hosted site but the host returned a 405 error, meaning that the host is blocking POST requests on /xmlrpc.php file.") + case .forbidden: + return NSLocalizedString("Couldn't connect. We received a 403 error when trying to access your site's XMLRPC endpoint. The app needs that in order to communicate with your site. Please contact your hosting provider to solve this problem.", comment: "Message to show to user when he tries to add a self-hosted site but the host returned a 403 error, meaning that the access to the /xmlrpc.php file is forbidden.") + case .xmlrpc_missing: + return NSLocalizedString("Couldn't connect. Required XML-RPC methods are missing on the server. Please contact your hosting provider to solve this problem.", comment: "Message to show to user when he tries to add a self-hosted site with RSD link present, but xmlrpc is missing.") + } + } +} + +extension WordPressOrgXMLRPCValidatorError: LocalizedError { + public var errorDescription: String? { + localizedDescription + } +} + +/// An WordPressOrgXMLRPCValidator is able to validate and check if user provided site urls are +/// WordPress XMLRPC sites. +open class WordPressOrgXMLRPCValidator: NSObject { + + // The documentation for NSURLErrorHTTPTooManyRedirects says that 16 + // is the default threshold for allowable redirects. + private let redirectLimit = 16 + + private let appTransportSecuritySettings: AppTransportSecuritySettings + + override public init() { + appTransportSecuritySettings = AppTransportSecuritySettings() + super.init() + } + + init(_ appTransportSecuritySettings: AppTransportSecuritySettings) { + self.appTransportSecuritySettings = appTransportSecuritySettings + super.init() + } + + /** + Validates and check if user provided site urls are WordPress XMLRPC sites and returns the API endpoint. + + - parameter site: the user provided site URL + - parameter userAgent: user agent for anonymous .com API to check if a site is a Jetpack site + - parameter success: completion handler that is invoked when the site is considered valid, + the xmlrpcURL argument is the endpoint + - parameter failure: completion handler that is invoked when the site is considered invalid, + the error object provides details why the endpoint is invalid + */ + @objc open func guessXMLRPCURLForSite(_ site: String, + userAgent: String, + success: @escaping (_ xmlrpcURL: URL) -> Void, + failure: @escaping (_ error: NSError) -> Void) { + + var sitesToTry = [String]() + + let secureAccessOnly: Bool = { + if let url = URL(string: site) { + return appTransportSecuritySettings.secureAccessOnly(for: url) + } + return true + }() + + if site.hasPrefix("http://") { + if !secureAccessOnly { + sitesToTry.append(site) + } + sitesToTry.append(site.replacingOccurrences(of: "http://", with: "https://")) + } else if site.hasPrefix("https://") { + sitesToTry.append(site) + if !secureAccessOnly { + sitesToTry.append(site.replacingOccurrences(of: "https://", with: "http://")) + } + } else { + failure(WordPressOrgXMLRPCValidatorError.invalidScheme as NSError) + return + } + + tryGuessXMLRPCURLForSites(sitesToTry, userAgent: userAgent, success: success, failure: failure) + } + + /// Helper for `guessXMLRPCURLForSite(_:userAgent:success:failure)` + /// Tries to guess the XMLRPC url for all the sites string given in the sites array + /// If all of them fail, it will call `failure` with the error in the last url string. + /// + private func tryGuessXMLRPCURLForSites(_ sites: [String], + userAgent: String, + success: @escaping (_ xmlrpcURL: URL) -> Void, + failure: @escaping (_ error: NSError) -> Void) { + + guard sites.isEmpty == false else { + failure(WordPressOrgXMLRPCValidatorError.invalid as NSError) + return + } + + var mutableSites = sites + let nextSite = mutableSites.removeFirst() + + func errorHandler(_ error: NSError) { + if mutableSites.isEmpty { + failure(error) + } else { + tryGuessXMLRPCURLForSites(mutableSites, userAgent: userAgent, success: success, failure: failure) + } + } + + let originalXMLRPCURL: URL + let xmlrpcURL: URL + do { + xmlrpcURL = try urlForXMLRPCFromURLString(nextSite, addXMLRPC: true) + originalXMLRPCURL = try urlForXMLRPCFromURLString(nextSite, addXMLRPC: false) + } catch let error as NSError { + // WPKitLogError(error.localizedDescription) + errorHandler(error) + return + } + + validateXMLRPCURL(xmlrpcURL, success: success, failure: { (error) in + // WPKitLogError(error.localizedDescription) + if (error.domain == NSURLErrorDomain && error.code == NSURLErrorUserCancelledAuthentication) || + (error.domain == NSURLErrorDomain && error.code == NSURLErrorCannotFindHost) || + (error.domain == NSURLErrorDomain && error.code == NSURLErrorNetworkConnectionLost) || + (error.domain == String(reflecting: WordPressOrgXMLRPCValidatorError.self) && error.code == WordPressOrgXMLRPCValidatorError.mobilePluginRedirectedError.rawValue) { + errorHandler(error) + return + } + // Try the original given url as an XML-RPC endpoint + // WPKitLogError("Try the original given url as an XML-RPC endpoint: \(originalXMLRPCURL)") + self.validateXMLRPCURL(originalXMLRPCURL, success: success, failure: { (error) in + // WPKitLogError(error.localizedDescription) + // Fetch the original url and look for the RSD link + self.guessXMLRPCURLFromHTMLURL(originalXMLRPCURL, success: success, failure: { (error) in + // WPKitLogError(error.localizedDescription) + + errorHandler(error) + }) + }) + }) + } + + private func urlForXMLRPCFromURLString(_ urlString: String, addXMLRPC: Bool) throws -> URL { + var resultURLString = urlString + // Is an empty url? Sorry, no psychic powers yet + resultURLString = urlString.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) + if resultURLString.isEmpty { + throw WordPressOrgXMLRPCValidatorError.emptyURL + } + + // Check if it's a valid URL + // Not a valid URL. Could be a bad protocol (htpp://), syntax error (http//), ... + // See https://github.com/koke/NSURL-Guess for extra help cleaning user typed URLs + guard let baseURL = URL(string: resultURLString) else { + throw WordPressOrgXMLRPCValidatorError.invalidURL + } + + // Let's see if a scheme is provided and it's HTTP or HTTPS + var scheme = baseURL.scheme!.lowercased() + if scheme.isEmpty { + resultURLString = "http://\(resultURLString)" + scheme = "http" + } + + guard scheme == "http" || scheme == "https" else { + throw WordPressOrgXMLRPCValidatorError.invalidScheme + } + + if baseURL.lastPathComponent != "xmlrpc.php" && addXMLRPC { + // Assume the given url is the home page and XML-RPC sits at /xmlrpc.php + // WPKitLogInfo("Assume the given url is the home page and XML-RPC sits at /xmlrpc.php") + resultURLString = "\(resultURLString)/xmlrpc.php" + } + + guard let url = URL(string: resultURLString) else { + throw WordPressOrgXMLRPCValidatorError.invalid + } + + return url + } + + private func validateXMLRPCURL(_ url: URL, + redirectCount: Int = 0, + success: @escaping (_ xmlrpcURL: URL) -> Void, + failure: @escaping (_ error: NSError) -> Void) { + + guard redirectCount < redirectLimit else { + let error = NSError(domain: URLError.errorDomain, + code: URLError.httpTooManyRedirects.rawValue, + userInfo: nil) + failure(error) + return + } + let api = WordPressOrgXMLRPCApi(endpoint: url) + api.callMethod("system.listMethods", parameters: nil, success: { (responseObject, httpResponse) in + guard let methods = responseObject as? [String], methods.contains("wp.getUsersBlogs") else { + failure(WordPressOrgXMLRPCValidatorError.notWordPressError as NSError) + return + } + if let finalURL = httpResponse?.url { + success(finalURL) + } else { + failure(WordPressOrgXMLRPCValidatorError.invalid as NSError) + } + }, failure: { (error, httpResponse) in + if httpResponse?.url != url { + // we where redirected, let's check the answer content + if let data = (error as NSError).userInfo[WordPressOrgXMLRPCApi.WordPressOrgXMLRPCApiErrorKeyData as String] as? Data, + let responseString = String(data: data, encoding: String.Encoding.utf8), responseString.range(of: "") != nil + || responseString.range(of: "dm404Container") != nil { + failure(WordPressOrgXMLRPCValidatorError.mobilePluginRedirectedError as NSError) + return + } + // If it's a redirect to the same host + // and the response is a '405 Method Not Allowed' + if let responseUrl = httpResponse?.url, + responseUrl.host == url.host + && httpResponse?.statusCode == 405 { + // Then it's likely a good redirect, but the POST + // turned into a GET. + // Let's retry the request at the new URL. + self.validateXMLRPCURL(responseUrl, redirectCount: redirectCount + 1, success: success, failure: failure) + return + } + } + + switch httpResponse?.statusCode { + case .some(WordPressOrgXMLRPCValidatorError.forbidden.rawValue): + failure(WordPressOrgXMLRPCValidatorError.forbidden as NSError) + case .some(WordPressOrgXMLRPCValidatorError.blocked.rawValue): + failure(WordPressOrgXMLRPCValidatorError.blocked as NSError) + default: + failure(error as NSError) + } + }) + } + + private func guessXMLRPCURLFromHTMLURL(_ htmlURL: URL, + success: @escaping (_ xmlrpcURL: URL) -> Void, + failure: @escaping (_ error: NSError) -> Void) { + // WPKitLogInfo("Fetch the original url and look for the RSD link by using RegExp") + + var isWpSite = false + let session = URLSession(configuration: URLSessionConfiguration.ephemeral) + let dataTask = session.dataTask(with: htmlURL, completionHandler: { (data, _, error) in + if let error { + failure(error as NSError) + return + } + guard let data, + let responseString = String(data: data, encoding: String.Encoding.utf8), + let rsdURL = self.extractRSDURLFromHTML(responseString) + else { + failure(WordPressOrgXMLRPCValidatorError.invalid as NSError) + return + } + + // If the site contains RSD link, it is WP.org site + isWpSite = true + + // Try removing "?rsd" from the url, it should point to the XML-RPC endpoint + let xmlrpc = rsdURL.replacingOccurrences(of: "?rsd", with: "") + if xmlrpc != rsdURL { + guard let newURL = URL(string: xmlrpc) else { + failure(WordPressOrgXMLRPCValidatorError.invalid as NSError) + return + } + self.validateXMLRPCURL(newURL, success: success, failure: { (error) in + // Try to validate by using the RSD file directly + if error.code == 403 || error.code == 405, let xmlrpcValidatorError = error as? WordPressOrgXMLRPCValidatorError { + failure(xmlrpcValidatorError as NSError) + } else { + let validatorError = isWpSite ? WordPressOrgXMLRPCValidatorError.xmlrpc_missing : + WordPressOrgXMLRPCValidatorError.invalid + failure(validatorError as NSError) + } + }) + } else { + // Try to validate by using the RSD file directly + self.guessXMLRPCURLFromRSD(rsdURL, success: success, failure: failure) + } + }) + dataTask.resume() + } + + private func extractRSDURLFromHTML(_ html: String) -> String? { + guard let rsdURLRegExp = try? NSRegularExpression(pattern: "", + options: [.caseInsensitive]) + else { + return nil + } + + let matches = rsdURLRegExp.matches(in: html, + options: NSRegularExpression.MatchingOptions(), + range: NSRange(location: 0, length: html.count)) + if matches.count <= 0 { + return nil + } + +#if swift(>=4.0) + let rsdURLRange = matches[0].range(at: 1) +#else + let rsdURLRange = matches[0].rangeAt(1) +#endif + + if rsdURLRange.location == NSNotFound { + return nil + } + let rsdURL = (html as NSString).substring(with: rsdURLRange) + return rsdURL + } + + private func guessXMLRPCURLFromRSD(_ rsd: String, + success: @escaping (_ xmlrpcURL: URL) -> Void, + failure: @escaping (_ error: NSError) -> Void) { + // WPKitLogInfo("Parse the RSD document at the following URL: \(rsd)") + guard let rsdURL = URL(string: rsd) else { + failure(WordPressOrgXMLRPCValidatorError.invalid as NSError) + return + } + let session = URLSession(configuration: URLSessionConfiguration.ephemeral) + let dataTask = session.dataTask(with: rsdURL, completionHandler: { (data, _, error) in + if let error { + failure(error as NSError) + return + } + guard let data, + let responseString = String(data: data, encoding: String.Encoding.utf8), + let parser = WordPressRSDParser(xmlString: responseString), + let xmlrpc = try? parser.parsedEndpoint(), + let xmlrpcURL = URL(string: xmlrpc) + else { + failure(WordPressOrgXMLRPCValidatorError.invalid as NSError) + return + } + // WPKitLogInfo("Bingo! We found the WordPress XML-RPC element: \(xmlrpcURL)") + self.validateXMLRPCURL(xmlrpcURL, success: success, failure: failure) + }) + dataTask.resume() + } +} diff --git a/Modules/Sources/WordPressKit/WordPressRSDParser.swift b/Modules/Sources/WordPressKit/WordPressRSDParser.swift new file mode 100644 index 000000000000..5d6d1f784013 --- /dev/null +++ b/Modules/Sources/WordPressKit/WordPressRSDParser.swift @@ -0,0 +1,53 @@ +import Foundation + +/// An WordPressRSDParser is able to parse an RSD file and search for the XMLRPC WordPress url. +open class WordPressRSDParser: NSObject, XMLParserDelegate { + + private let parser: XMLParser + private var endpoint: String? + + @objc init?(xmlString: String) { + guard let data = xmlString.data(using: String.Encoding.utf8) else { + return nil + } + parser = XMLParser(data: data) + super.init() + parser.delegate = self + } + + func parsedEndpoint() throws -> String? { + if parser.parse() { + return endpoint + } + // Return the 'WordPress' API link, if found. + if let endpoint { + return endpoint + } + guard let error = parser.parserError else { + return nil + } + throw error + } + + // MARK: - NSXMLParserDelegate + open func parser(_ parser: XMLParser, + didStartElement elementName: String, + namespaceURI: String?, + qualifiedName qName: String?, + attributes attributeDict: [String: String]) { + if elementName == "api" { + if let apiName = attributeDict["name"], apiName == "WordPress" { + if let endpoint = attributeDict["apiLink"] { + self.endpoint = endpoint + } else { + parser.abortParsing() + } + } + } + } + + open func parser(_ parser: XMLParser, parseErrorOccurred parseError: Error) { + // WPKitLogInfo("Error parsing RSD: \(parseError)") + } + +} diff --git a/Modules/Sources/WordPressKit/ZendeskMetadata.swift b/Modules/Sources/WordPressKit/ZendeskMetadata.swift new file mode 100644 index 000000000000..3956a6628d2d --- /dev/null +++ b/Modules/Sources/WordPressKit/ZendeskMetadata.swift @@ -0,0 +1,35 @@ +import Foundation +public struct ZendeskSiteContainer: Decodable { + public let sites: [ZendeskSite] +} + +public struct ZendeskSite: Decodable { + public let ID: Int + public let zendeskMetadata: ZendeskMetadata + + private enum CodingKeys: String, CodingKey { + case ID = "ID" + case zendeskMetadata = "zendesk_site_meta" + } +} + +public struct ZendeskMetadata: Decodable { + public let plan: String + public let jetpackAddons: [String] + + private enum CodingKeys: String, CodingKey { + case plan = "plan" + case jetpackAddons = "addon" + } + + public init(plan: String, jetpackAddons: [String]) { + self.plan = plan + self.jetpackAddons = jetpackAddons + } +} + +/// Errors generated by the metadata decoding process +public enum PlanServiceRemoteError: Error { + // thrown when no metadata were found + case noMetadata +} diff --git a/Modules/Sources/WordPressKitModels/Date+WordPressCom.swift b/Modules/Sources/WordPressKitModels/Date+WordPressCom.swift new file mode 100644 index 000000000000..3bbc47e053ad --- /dev/null +++ b/Modules/Sources/WordPressKitModels/Date+WordPressCom.swift @@ -0,0 +1,21 @@ +import Foundation + +extension Date { + + /// Parses a date string + /// + /// Dates in the format specified in http://www.w3.org/TR/NOTE-datetime should be OK. + /// The kind of dates returned by the REST API should match that format, even if the doc promises ISO 8601. + /// + /// Parsing the full ISO 8601, or even RFC 3339 is more complex than this, and makes no sense right now. + /// + /// - SeeAlso: [WordPress.com REST API docs](https://developer.wordpress.com/docs/api/) + /// - Warning: This method doesn't support fractional seconds or dates with leap seconds (23:59:60 turns into 23:59:00) + static func with(wordPressComJSONString jsonString: String) -> Date? { + DateFormatter.wordPressCom.date(from: jsonString) + } + + var wordPressComJSONString: String { + DateFormatter.wordPressCom.string(from: self) + } +} diff --git a/Modules/Sources/WordPressKitModels/DateFormatter+WordPressCom.swift b/Modules/Sources/WordPressKitModels/DateFormatter+WordPressCom.swift new file mode 100644 index 000000000000..522f072fbf47 --- /dev/null +++ b/Modules/Sources/WordPressKitModels/DateFormatter+WordPressCom.swift @@ -0,0 +1,15 @@ +import Foundation + +extension DateFormatter { + + /// A `DateFormatter` configured to manage dates compatible with the WordPress.com API. + /// + /// - SeeAlso: [https://developer.wordpress.com/docs/api/](https://developer.wordpress.com/docs/api/) + package static let wordPressCom: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy'-'MM'-'dd'T'HH':'mm':'ssZ" + formatter.timeZone = NSTimeZone(forSecondsFromGMT: 0) as TimeZone + formatter.locale = NSLocale(localeIdentifier: "en_US_POSIX") as Locale + return formatter + }() +} diff --git a/Modules/Sources/WordPressKitModels/NSCharacterSet+URLEncode.swift b/Modules/Sources/WordPressKitModels/NSCharacterSet+URLEncode.swift new file mode 100644 index 000000000000..c0e45b9c9f3a --- /dev/null +++ b/Modules/Sources/WordPressKitModels/NSCharacterSet+URLEncode.swift @@ -0,0 +1,12 @@ +import Foundation + +@objc +public extension NSCharacterSet { + /// The base character set `urlPathAllowed` allows single apostrophes. This encoding is a bit more + /// restrictive and disallows some extra characters as per RFC 3986. + /// + @objc(URLPathRFC3986AllowedCharacterSet) + static var urlPathRFC3986Allowed: CharacterSet { + CharacterSet.urlPathAllowed.subtracting(CharacterSet(charactersIn: "!'()*")) + } +} diff --git a/Modules/Sources/WordPressKitModels/NSDate+Helpers.swift b/Modules/Sources/WordPressKitModels/NSDate+Helpers.swift new file mode 100644 index 000000000000..e73b051c9d2e --- /dev/null +++ b/Modules/Sources/WordPressKitModels/NSDate+Helpers.swift @@ -0,0 +1,261 @@ +import Foundation + +extension Date { + /// Private Date Formatters + /// + fileprivate struct DateFormatters { + // swiftlint:disable operator_usage_whitespace + static let iso8601: DateFormatter = { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZZZZZ" + formatter.timeZone = TimeZone(secondsFromGMT: 0) + return formatter + }() + + static let iso8601WithMilliseconds: DateFormatter = { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSXXXXX" + formatter.timeZone = TimeZone(secondsFromGMT: 0) + return formatter + }() + + static let rfc1123: DateFormatter = { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "EEE, dd MMM yyyy HH:mm:ss z" + formatter.timeZone = TimeZone(secondsFromGMT: 0) + return formatter + }() + // swiftlint:enable operator_usage_whitespace + + static let mediumDate: DateFormatter = { + let formatter = DateFormatter() + formatter.dateStyle = .medium + formatter.timeStyle = .none + return formatter + }() + + static let mediumDateTime: DateFormatter = { + let formatter = DateFormatter() + formatter.doesRelativeDateFormatting = true + formatter.dateStyle = .medium + formatter.timeStyle = .short + return formatter + }() + + static let mediumUTCDateTime: DateFormatter = { + let formatter = DateFormatter() + formatter.dateStyle = .medium + formatter.timeStyle = .short + formatter.timeZone = TimeZone(secondsFromGMT: 0) + return formatter + }() + + static let longUTCDate: DateFormatter = { + let formatter = DateFormatter() + formatter.dateStyle = .long + formatter.timeStyle = .none + formatter.timeZone = TimeZone(secondsFromGMT: 0) + return formatter + }() + + static let shortDateTime: DateFormatter = { + let formatter = DateFormatter() + formatter.doesRelativeDateFormatting = true + formatter.dateStyle = .short + formatter.timeStyle = .short + return formatter + }() + } + + /// Returns a NSDate Instance, given it's ISO8601 String Representation + /// + static func dateWithISO8601String(_ string: String) -> Date? { + return DateFormatters.iso8601.date(from: string) + } + + /// Returns a NSDate Instance, given it's ISO8601 String Representation with milliseconds + /// + package static func dateWithISO8601WithMillisecondsString(_ string: String) -> Date? { + return DateFormatters.iso8601WithMilliseconds.date(from: string) + } + + /// Returns a NSDate instance with only its Year / Month / Weekday / Day set. Removes the time! + /// + func normalizedDate() -> Date { + + // swiftlint:disable operator_usage_whitespace + var calendar = Calendar.current + calendar.timeZone = TimeZone.autoupdatingCurrent + + let flags: NSCalendar.Unit = [.day, .weekOfYear, .month, .year] + + let components = (calendar as NSCalendar).components(flags, from: self) + + var normalized = DateComponents() + normalized.year = components.year + normalized.month = components.month + normalized.weekday = components.weekday + normalized.day = components.day + // swiftlint:enable operator_usage_whitespace + + return calendar.date(from: normalized) ?? self + } + + /// Formats the current NSDate instance using the RFC1123 Standard + /// + func toStringAsRFC1123() -> String { + return DateFormatters.rfc1123.string(from: self) + } + + @available(*, deprecated, renamed: "toMediumString", message: "Removed to help drop the deprecated `FormatterKit` dependency – @jkmassel, Mar 2021") + func mediumString(timeZone: TimeZone? = nil) -> String { + toMediumString(inTimeZone: timeZone) + } + + /// Formats the current date as relative date if it's within a week of + /// today, or with DateFormatter.Style.medium otherwise. + /// - Parameter timeZone: An optional time zone used to adjust the date formatters. **NOTE**: This has no affect on relative time stamps. + /// + /// - Example: 22 hours from now + /// - Example: 5 minutes ago + /// - Example: 8 hours ago + /// - Example: 2 days ago + /// - Example: Jan 22, 2017 + /// + func toMediumString(inTimeZone timeZone: TimeZone? = nil) -> String { + let relativeFormatter = RelativeDateTimeFormatter() + relativeFormatter.dateTimeStyle = .named + + let absoluteFormatter = DateFormatters.mediumDate + + if let timeZone { + absoluteFormatter.timeZone = timeZone + } + + let components = Calendar.current.dateComponents([.day], from: self, to: Date()) + if let days = components.day, abs(days) < 7 { + return relativeFormatter.localizedString(fromTimeInterval: timeIntervalSinceNow) + } else { + return absoluteFormatter.string(from: self) + } + } + + /// Formats the current date as a medium relative date/time. + /// That is, it uses the `DateFormatter` `dateStyle` `.medium` and `timeStyle` `.short`. + /// + /// - Parameter timeZone: An optional time zone used to adjust the date formatters. + func mediumStringWithTime(timeZone: TimeZone? = nil) -> String { + let formatter = DateFormatters.mediumDateTime + if let timeZone { + formatter.timeZone = timeZone + } + return formatter.string(from: self) + } + + /// Formats the current date as (non relative) long date (no time) in UTC. + /// + /// - Example: January 6th, 2018 + /// + func longUTCStringWithoutTime() -> String { + return DateFormatters.longUTCDate.string(from: self) + } + + /// Formats the current date as (non relattive) medium date/time in UTC. + /// + /// - Example: Jan 28, 2017, 1:51 PM + /// + func mediumStringWithUTCTime() -> String { + return DateFormatters.mediumUTCDateTime.string(from: self) + } + + /// Formats the current date as a short relative date/time. + /// + /// - Example: Tomorrow, 6:45 AM + /// - Example: Today, 8:09 AM + /// - Example: Yesterday, 11:36 PM + /// - Example: 1/28/17, 1:51 PM + /// - Example: 1/22/17, 2:18 AM + /// + func shortStringWithTime() -> String { + return DateFormatters.shortDateTime.string(from: self) + } + + @available(*, deprecated, message: "Not used, as far as I can tell – @jkmassel, Jan 2021") + fileprivate func toStringForPageSections() -> String { + let interval = timeIntervalSinceNow + + if interval > 0 && interval < 86400 { + return NSLocalizedString("later today", comment: "Later today") + } else { + let formatter = RelativeDateTimeFormatter() + formatter.unitsStyle = .short + formatter.dateTimeStyle = .named + + return formatter.localizedString(fromTimeInterval: interval) + } + } + + /// Returns the date components object. + /// + func dateAndTimeComponents() -> DateComponents { + return Calendar.current.dateComponents([.year, .month, .day, .hour, .minute, .second], + from: self) + } +} + +extension NSDate { + @objc public static func wpkit_dateWithISO8601String(_ string: String) -> NSDate? { + return Date.DateFormatters.iso8601.date(from: string) as NSDate? + } + + /// Formats the current date as relative date if it's within a week of + /// today, or with NSDateFormatterMediumStyle otherwise. + /// + /// - Example: 22 hours from now + /// - Example: 5 minutes ago + /// - Example: 8 hours ago + /// - Example: 2 days ago + /// - Example: Jan 22, 2017 + /// + @objc func mediumString() -> String { + return (self as Date).toMediumString() + } + + /// Formats the current date as a medium relative date/time. + /// + /// - Example: Tomorrow, 6:45 AM + /// - Example: Today, 8:09 AM + /// - Example: Yesterday, 11:36 PM + /// - Example: Jan 28, 2017, 1:51 PM + /// - Example: Jan 22, 2017, 2:18 AM + /// + @objc func mediumStringWithTime() -> String { + return (self as Date).mediumStringWithTime() + } + + /// Formats the current date as a short relative date/time. + /// + /// - Example: Tomorrow, 6:45 AM + /// - Example: Today, 8:09 AM + /// - Example: Yesterday, 11:36 PM + /// - Example: 1/28/17, 1:51 PM + /// - Example: 1/22/17, 2:18 AM + /// + @objc func shortStringWithTime() -> String { + return (self as Date).shortStringWithTime() + } + + @available(*, deprecated, message: "Scheduled for removal with FormatterKit – if it's still used, we'll rewrite it with modern APIs") + @objc func toStringForPageSections() -> String { + return (self as Date).toStringForPageSections() + } + + /// Returns the date components object. + /// + @objc func dateAndTimeComponents() -> NSDateComponents { + return (self as Date).dateAndTimeComponents() as NSDateComponents + } +} diff --git a/Modules/Sources/WordPressKitModels/NSDate+WordPressCom.swift b/Modules/Sources/WordPressKitModels/NSDate+WordPressCom.swift new file mode 100644 index 000000000000..2170399c34d5 --- /dev/null +++ b/Modules/Sources/WordPressKitModels/NSDate+WordPressCom.swift @@ -0,0 +1,30 @@ +import Foundation + +// This `NSDate` extension wraps the `Date` implementation. +// +// It's done in two types because we cannot expose the `Date` methods to Objective-C, since `Date` is not a class: +// +// `@objc can only be used with members of classes, @objc protocols, and concrete extensions of classes` +extension NSDate { + + /// Parses a date string + /// + /// Dates in the format specified in http://www.w3.org/TR/NOTE-datetime should be OK. + /// The kind of dates returned by the REST API should match that format, even if the doc promises ISO 8601. + /// + /// Parsing the full ISO 8601, or even RFC 3339 is more complex than this, and makes no sense right now. + /// + /// - SeeAlso: [WordPress.com REST API docs](https://developer.wordpress.com/docs/api/) + /// - Warning: This method doesn't support fractional seconds or dates with leap seconds (23:59:60 turns into 23:59:00) + // + // Needs to be `public` because of the usages in the Objective-C code. + @objc(dateWithWordPressComJSONString:) + public static func with(wordPressComJSONString jsonString: String) -> Date? { + Date.with(wordPressComJSONString: jsonString) + } + + @objc(WordPressComJSONString) + public func wordPressComJSONString() -> String { + (self as Date).wordPressComJSONString + } +} diff --git a/Modules/Sources/WordPressKitModels/NSString+Summary.swift b/Modules/Sources/WordPressKitModels/NSString+Summary.swift new file mode 100644 index 000000000000..dea8017ee3cb --- /dev/null +++ b/Modules/Sources/WordPressKitModels/NSString+Summary.swift @@ -0,0 +1,80 @@ +import Foundation +import WordPressKitObjCUtils + +/// This is an extension to NSString that provides logic to summarize HTML content, +/// and convert HTML into plain text. +/// +extension NSString { + + static let PostDerivedSummaryLength = 150 + + /// Create a summary for the post based on the post's content. + /// + /// - Returns: A summary for the post. + /// + @objc + public func wpkit_summarized() -> String { + let characterSet = CharacterSet(charactersIn: "\n") + + return (self as String).strippingGutenbergContentForExcerpt() + .strippingShortcodes() + .makePlainText() + .trimmingCharacters(in: characterSet) + .wpkit_stringByEllipsizing(withMaxLength: NSString.PostDerivedSummaryLength, preserveWords: true) + } +} + +private extension String { + func makePlainText() -> String { + let characterSet = NSCharacterSet.whitespacesAndNewlines + + return self.wpkit_stringByStrippingHTML() + .wpkit_stringByDecodingXMLCharacters() + .trimmingCharacters(in: characterSet) + } + + /// Creates a new string by stripping all shortcodes from this string. + /// + func strippingShortcodes() -> String { + let pattern = "\\[[^\\]]+\\]" + + return removingMatches(pattern: pattern, options: .caseInsensitive) + } + + /// This method is the main entry point to generate excerpts for Gutenberg content. + /// + func strippingGutenbergContentForExcerpt() -> String { + return strippingGutenbergGalleries().strippingGutenbergVideoPress() + } + + /// Strips Gutenberg galleries from strings. + /// + func strippingGutenbergGalleries() -> String { + let pattern = "(?s)" + + return removingMatches(pattern: pattern, options: .caseInsensitive) + } + + /// Strips VideoPress references from Gutenberg VideoPress and Video blocks. + /// + func strippingGutenbergVideoPress() -> String { + let pattern = "(?s)\n?" + + return removingMatches(pattern: pattern, options: .caseInsensitive) + } + + /// Creates a new string by removing all matches of the specified regex. + /// + func removingMatches(pattern: String, options: NSRegularExpression.Options = []) -> String { + let range = NSRange(location: 0, length: self.utf16.count) + let regex: NSRegularExpression + + do { + regex = try NSRegularExpression(pattern: pattern, options: options) + } catch { + return self + } + + return regex.stringByReplacingMatches(in: self, options: .reportCompletion, range: range, withTemplate: "") + } +} diff --git a/Modules/Sources/WordPressKitModels/ObjectValidation.swift b/Modules/Sources/WordPressKitModels/ObjectValidation.swift new file mode 100644 index 000000000000..8ecffe7a1793 --- /dev/null +++ b/Modules/Sources/WordPressKitModels/ObjectValidation.swift @@ -0,0 +1,21 @@ +import Foundation + +@objc public extension NSObject { + + /// Validate if a class is a valid NSObject and if it's not nil + /// + /// - Returns: Bool value + func wp_isValidObject() -> Bool { + return !(self is NSNull) + } +} + +@objc public extension NSString { + + /// Validate if a class is a valid NSString and if it's not nil + /// + /// - Returns: Bool value + func wp_isValidString() -> Bool { + return wp_isValidObject() && self != "" + } +} diff --git a/Modules/Sources/WordPressKitModels/RemoteBlog.swift b/Modules/Sources/WordPressKitModels/RemoteBlog.swift new file mode 100644 index 000000000000..c85ac3c48838 --- /dev/null +++ b/Modules/Sources/WordPressKitModels/RemoteBlog.swift @@ -0,0 +1,90 @@ +import Foundation + +/// This class encapsulates all of the *remote* Blog properties +@objcMembers public class RemoteBlog: NSObject { + + /// The ID of the Blog entity. + public var blogID: NSNumber + + /// The organization ID of the Blog entity. + public var organizationID: NSNumber + + /// Represents the Blog Name. + public var name: String + + /// Description of the WordPress Blog. + public var tagline: String? + + /// Represents the Blog Name. + public var url: String + + /// Maps to the XMLRPC endpoint. + public var xmlrpc: String? + + /// Site Icon's URL. + public var icon: String? + + /// Product ID of the site's current plan, if it has one. + public var planID: NSNumber? + + /// Product name of the site's current plan, if it has one. + public var planTitle: String? + + /// Indicates whether the current's blog plan is paid, or not. + public var hasPaidPlan: Bool = false + + /// Features available for the current blog's plan. + public var planActiveFeatures = [String]() + + /// Indicates whether the site is a Jetpack site or not. + public var jetpack: Bool = false + + /// Indicates whether the site is connected to WP.com via `jetpack-connection`. + public var jetpackConnection: Bool = false + + /// Boolean indicating whether the current user has Admin privileges, or not. + public var isAdmin: Bool = false + + /// Blog's visibility preferences. + public var visible: Bool = false + + /// Blog's options preferences. + public var options: NSDictionary + + /// Blog's capabilities: Indicate which actions are allowed / not allowed, for the current user. + public var capabilities: [String: Bool] + + /// Blog's total disk quota space. + public var quotaSpaceAllowed: NSNumber? + + /// Blog's total disk quota space used. + public var quotaSpaceUsed: NSNumber? + + public var isDeleted: Bool + + /// Parses details from a JSON dictionary, as returned by the WordPress.com REST API. + @objc(initWithJSONDictionary:) + public init(jsonDictionary json: NSDictionary) { + self.blogID = json.number(forKey: "ID") ?? 0 + self.organizationID = json.number(forKey: "organization_id") ?? 0 + self.name = json.string(forKey: "name") ?? "" + self.tagline = json.string(forKey: "description") + self.url = json.string(forKey: "URL") ?? "" + self.xmlrpc = json.string(forKeyPath: "meta.links.xmlrpc") + self.jetpack = json.number(forKey: "jetpack")?.boolValue ?? false + self.jetpackConnection = json.number(forKey: "jetpack_connection")?.boolValue ?? false + self.icon = json.string(forKeyPath: "icon.img") + self.capabilities = json.object(forKey: "capabilities") as? [String: Bool] ?? [:] + self.isAdmin = json.number(forKeyPath: "capabilities.manage_options")?.boolValue ?? false + self.visible = json.number(forKey: "visible")?.boolValue ?? false + self.options = RemoteBlogOptionsHelper.mapOptions(fromResponse: json) + self.planID = json.number(forKeyPath: "plan.product_id") + self.planTitle = json.string(forKeyPath: "plan.product_name_short") + self.hasPaidPlan = !(json.number(forKeyPath: "plan.is_free")?.boolValue ?? true) + self.planActiveFeatures = (json.array(forKeyPath: "plan.features.active") as? [String]) ?? [] + self.quotaSpaceAllowed = json.number(forKeyPath: "quota.space_allowed") + self.quotaSpaceUsed = json.number(forKeyPath: "quota.space_used") + self.isDeleted = json.number(forKey: "is_deleted")?.boolValue == true + } + +} diff --git a/Modules/Sources/WordPressKitModels/RemoteBlogOptionsHelper.swift b/Modules/Sources/WordPressKitModels/RemoteBlogOptionsHelper.swift new file mode 100644 index 000000000000..289fcce9f504 --- /dev/null +++ b/Modules/Sources/WordPressKitModels/RemoteBlogOptionsHelper.swift @@ -0,0 +1,82 @@ +import Foundation + +@objcMembers public class RemoteBlogOptionsHelper: NSObject { + + public class func mapOptions(fromResponse response: NSDictionary) -> NSDictionary { + let options = NSMutableDictionary() + options["home_url"] = response["URL"] + if response.number(forKey: "jetpack")?.boolValue == true { + options["jetpack_client_id"] = response.number(forKey: "ID") + } + if response.number(forKey: "is_wpcom_staging_site")?.boolValue == true { + options["is_wpcom_staging_site"] = true + } + if response["options"] != nil { + options["post_thumbnail"] = response.value(forKeyPath: "options.featured_images_enabled") + + let optionsDirectMapKeys = [ + "active_modules", + "admin_url", + "login_url", + "unmapped_url", + "image_default_link_type", + "software_version", + "videopress_enabled", + "timezone", + "gmt_offset", + "allowed_file_types", + "frame_nonce", + "jetpack_version", + "is_automated_transfer", + "blog_public", + "max_upload_size", + "is_wpcom_atomic", + "is_wpcom_staging_site", + "is_wpforteams_site", + "show_on_front", + "page_on_front", + "page_for_posts", + "blogging_prompts_settings", + "jetpack_connection_active_plugins", + "can_blaze" + ] + for key in optionsDirectMapKeys { + if let value = response.value(forKeyPath: "options.\(key)") { + options[key] = value + } + } + } + let valueOptions = NSMutableDictionary(capacity: options.count) + for (key, obj) in options { + valueOptions[key] = [ + "value": obj + ] + } + + return NSDictionary(dictionary: valueOptions) + } + + // Helper methods for converting between XMLRPC dictionaries and RemoteBlogSettings + // Currently, we are only ever updating the blog title or tagline through XMLRPC + // Brent - Jan 7, 2017 + public class func remoteOptionsForUpdatingBlogTitleAndTagline(_ blogSettings: RemoteBlogSettings) -> NSDictionary { + let options = NSMutableDictionary() + if let value = blogSettings.name { + options["blog_title"] = value + } + if let value = blogSettings.tagline { + options["blog_tagline"] = value + } + return options + } + + public class func remoteBlogSettings(fromXMLRPCDictionaryOptions options: NSDictionary) -> RemoteBlogSettings { + let remoteSettings = RemoteBlogSettings() + remoteSettings.name = options.string(forKeyPath: "blog_title.value")?.wpkit_stringByDecodingXMLCharacters() + remoteSettings.tagline = options.string(forKeyPath: "blog_tagline.value")?.wpkit_stringByDecodingXMLCharacters() + if options["blog_public"] != nil { + remoteSettings.privacy = options.number(forKeyPath: "blog_public.value") + } + return remoteSettings + } +} diff --git a/Modules/Sources/WordPressKitModels/RemoteBlogSettings.swift b/Modules/Sources/WordPressKitModels/RemoteBlogSettings.swift new file mode 100644 index 000000000000..333b44677c8c --- /dev/null +++ b/Modules/Sources/WordPressKitModels/RemoteBlogSettings.swift @@ -0,0 +1,206 @@ +import Foundation + +/// This class encapsulates all of the *remote* settings available for a Blog entity +/// +public class RemoteBlogSettings: NSObject { + // MARK: - General + + /// Represents the Blog Name. + /// + @objc public var name: String? + + /// Stores the Blog's Tagline setting. + /// + @objc public var tagline: String? + + /// Stores the Blog's Privacy Preferences Settings + /// + @objc public var privacy: NSNumber? + + /// Stores the Blog's Language ID Setting + /// + @objc public var languageID: NSNumber? + + /// Stores the Blog's Icon Media ID + /// + @objc public var iconMediaID: NSNumber? + + /// Stores the Blog's GMT offset + /// + @objc public var gmtOffset: NSNumber? + + /// Stores the Blog's timezone + /// + @objc public var timezoneString: String? + + // MARK: - Writing + + /// Contains the Default Category ID. Used when creating new posts. + /// + @objc public var defaultCategoryID: NSNumber? + + /// Contains the Default Post Format. Used when creating new posts. + /// + @objc public var defaultPostFormat: String? + + /// The blog's date format setting. + /// + @objc public var dateFormat: String? + + /// The blog's time format setting + /// + @objc public var timeFormat: String? + + /// The blog's chosen day to start the week setting + /// + @objc public var startOfWeek: String? + + /// Numbers of posts per page + /// + @objc public var postsPerPage: NSNumber? + + // MARK: - Discussion + + /// Represents whether comments are allowed, or not. + /// + @objc public var commentsAllowed: NSNumber? + + /// Contains a list of words that would automatically blocklist a comment. + /// + @objc public var commentsBlocklistKeys: String? + + /// If true, comments will be automatically closed after the number of days, specified by `commentsCloseAutomaticallyAfterDays`. + /// + @objc public var commentsCloseAutomatically: NSNumber? + + /// Represents the number of days comments will be enabled, granted that the `commentsCloseAutomatically` + /// property is set to true. + /// + @objc public var commentsCloseAutomaticallyAfterDays: NSNumber? + + /// When enabled, comments from known users will be allowlisted. + /// + @objc public var commentsFromKnownUsersAllowlisted: NSNumber? + + /// Indicates the maximum number of links allowed per comment. When a new comment exceeds this number, + /// it'll be held in queue for moderation. + /// + @objc public var commentsMaximumLinks: NSNumber? + + /// Contains a list of words that cause a comment to require moderation. + /// + @objc public var commentsModerationKeys: String? + + /// If true, comment pagination will be enabled. + /// + @objc public var commentsPagingEnabled: NSNumber? + + /// Specifies the number of comments per page. This will be used only if the property `commentsPagingEnabled` + /// is set to true. + /// + @objc public var commentsPageSize: NSNumber? + + /// When enabled, new comments will require Manual Moderation, before showing up. + /// + @objc public var commentsRequireManualModeration: NSNumber? + + /// If set to true, commenters will be required to enter their name and email. + /// + @objc public var commentsRequireNameAndEmail: NSNumber? + + /// Specifies whether commenters should be registered or not. + /// + @objc public var commentsRequireRegistration: NSNumber? + + /// Indicates the sorting order of the comments. Ascending / Descending, based on the date. + /// + @objc public var commentsSortOrder: String? + + /// Indicates the number of levels allowed per comment. + /// + @objc public var commentsThreadingDepth: NSNumber? + + /// When enabled, comment threading will be supported. + /// + @objc public var commentsThreadingEnabled: NSNumber? + + /// If set to true, 3rd party sites will be allowed to post pingbacks. + /// + @objc public var pingbackInboundEnabled: NSNumber? + + /// When Outbound Pingbacks are enabled, 3rd party sites that get linked will be notified. + /// + @objc public var pingbackOutboundEnabled: NSNumber? + + // MARK: - Related Posts + + /// When set to true, Related Posts will be allowed. + /// + @objc public var relatedPostsAllowed: NSNumber? + + /// When set to true, Related Posts will be enabled. + /// + @objc public var relatedPostsEnabled: NSNumber? + + /// Indicates whether related posts should show a headline. + /// + @objc public var relatedPostsShowHeadline: NSNumber? + + /// Indicates whether related posts should show thumbnails. + /// + @objc public var relatedPostsShowThumbnails: NSNumber? + + // MARK: - AMP + + /// Indicates if AMP is supported on the site + /// + @objc public var ampSupported: NSNumber? + + /// Indicates if AMP is enabled on the site + /// + @objc public var ampEnabled: NSNumber? + + // MARK: - Sharing + + /// Indicates the style to use for the sharing buttons on a particular blog.. + /// + @objc public var sharingButtonStyle: String? + + /// The title of the sharing label on the user's blog. + /// + @objc public var sharingLabel: String? + + /// Indicates the twitter username to use when sharing via Twitter + /// + @objc public var sharingTwitterName: String? + + /// Indicates whether related posts should show thumbnails. + /// + @objc public var sharingCommentLikesEnabled: NSNumber? + + /// Indicates whether sharing via post likes has been disabled + /// + @objc public var sharingDisabledLikes: NSNumber? + + /// Indicates whether sharing by reblogging has been disabled + /// + @objc public var sharingDisabledReblogs: NSNumber? + + // MARK: - Helpers + + /// Computed property, meant to help conversion from Remote / String-Based values, into their Integer counterparts + /// + @objc public var commentsSortOrderAscending: Bool { + set { + commentsSortOrder = newValue ? RemoteBlogSettings.AscendingStringValue : RemoteBlogSettings.DescendingStringValue + } + get { + return commentsSortOrder == RemoteBlogSettings.AscendingStringValue + } + } + + // MARK: - Private + + private static let AscendingStringValue = "asc" + private static let DescendingStringValue = "desc" +} diff --git a/Modules/Sources/WordPressKitModels/RemoteMenu.swift b/Modules/Sources/WordPressKitModels/RemoteMenu.swift new file mode 100644 index 000000000000..203533e95dab --- /dev/null +++ b/Modules/Sources/WordPressKitModels/RemoteMenu.swift @@ -0,0 +1,11 @@ +import Foundation + +@objcMembers public class RemoteMenu: NSObject { + + public var menuID: NSNumber? + public var details: String? + public var name: String? + public var items: [RemoteMenuItem]? + public var locationNames: [String]? + +} diff --git a/Modules/Sources/WordPressKitModels/RemoteMenuItem.swift b/Modules/Sources/WordPressKitModels/RemoteMenuItem.swift new file mode 100644 index 000000000000..99abc029f31b --- /dev/null +++ b/Modules/Sources/WordPressKitModels/RemoteMenuItem.swift @@ -0,0 +1,19 @@ +import Foundation + +@objcMembers public class RemoteMenuItem: NSObject { + + public var itemID: NSNumber? + public var contentID: NSNumber? + public var details: String? + public var linkTarget: String? + public var linkTitle: String? + public var name: String? + public var type: String? + public var typeFamily: String? + public var typeLabel: String? + public var urlStr: String? + public var classes: [String]? + public var children: [RemoteMenuItem]? + public weak var parentItem: RemoteMenuItem? + +} diff --git a/Modules/Sources/WordPressKitModels/RemoteMenuLocation.swift b/Modules/Sources/WordPressKitModels/RemoteMenuLocation.swift new file mode 100644 index 000000000000..f0d2978033b3 --- /dev/null +++ b/Modules/Sources/WordPressKitModels/RemoteMenuLocation.swift @@ -0,0 +1,9 @@ +import Foundation + +@objcMembers public class RemoteMenuLocation: NSObject { + + public var name: String? + public var defaultState: String? + public var details: String? + +} diff --git a/Modules/Sources/WordPressKitModels/RemotePostAutosave.swift b/Modules/Sources/WordPressKitModels/RemotePostAutosave.swift new file mode 100644 index 000000000000..fcc7c382e6b9 --- /dev/null +++ b/Modules/Sources/WordPressKitModels/RemotePostAutosave.swift @@ -0,0 +1,15 @@ +import Foundation + +/// Encapsulates the autosave attributes of a post. +@objc +@objcMembers +public class RemotePostAutosave: NSObject { + public var title: String? + public var excerpt: String? + public var content: String? + public var modifiedDate: Date? + public var identifier: NSNumber? + public var authorID: String? + public var postID: NSNumber? + public var previewURL: String? +} diff --git a/Modules/Sources/WordPressKitModels/RemoteReaderCrossPostMeta.swift b/Modules/Sources/WordPressKitModels/RemoteReaderCrossPostMeta.swift new file mode 100644 index 000000000000..96c444fb25e9 --- /dev/null +++ b/Modules/Sources/WordPressKitModels/RemoteReaderCrossPostMeta.swift @@ -0,0 +1,9 @@ +import Foundation + +open class RemoteReaderCrossPostMeta: NSObject { + @objc open var postID: NSNumber = 0 + @objc open var siteID: NSNumber = 0 + @objc open var siteURL = "" + @objc open var postURL = "" + @objc open var commentURL = "" +} diff --git a/Modules/Sources/WordPressKitModels/RemoteReaderSite.swift b/Modules/Sources/WordPressKitModels/RemoteReaderSite.swift new file mode 100644 index 000000000000..6de803c9b32a --- /dev/null +++ b/Modules/Sources/WordPressKitModels/RemoteReaderSite.swift @@ -0,0 +1,13 @@ +import Foundation + +@objcMembers public class RemoteReaderSite: NSObject { + + public var recordID: NSNumber! + public var siteID: NSNumber! + public var feedID: NSNumber! + public var name: String! + public var path: String! // URL + public var icon: String! // Sites only + public var isSubscribed: Bool = false + +} diff --git a/Modules/Sources/WordPressKitModels/RemoteReaderSiteInfo.swift b/Modules/Sources/WordPressKitModels/RemoteReaderSiteInfo.swift new file mode 100644 index 000000000000..cde802405277 --- /dev/null +++ b/Modules/Sources/WordPressKitModels/RemoteReaderSiteInfo.swift @@ -0,0 +1,154 @@ +import Foundation +import NSObject_SafeExpectations + +// Site Topic Keys +private let SiteDictionaryFeedIDKey = "feed_ID" +private let SiteDictionaryFeedURLKey = "feed_URL" +private let SiteDictionaryFollowingKey = "is_following" +private let SiteDictionaryJetpackKey = "is_jetpack" +private let SiteDictionaryOrganizationID = "organization_id" +private let SiteDictionaryPrivateKey = "is_private" +private let SiteDictionaryVisibleKey = "visible" +private let SiteDictionaryPostCountKey = "post_count" +private let SiteDictionaryIconPathKey = "icon.img" +private let SiteDictionaryDescriptionKey = "description" +private let SiteDictionaryIDKey = "ID" +private let SiteDictionaryNameKey = "name" +private let SiteDictionaryURLKey = "URL" +private let SiteDictionarySubscriptionsKey = "subscribers_count" +private let SiteDictionarySubscriptionKey = "subscription" +private let SiteDictionaryUnseenCountKey = "unseen_count" + +// Subscription keys +private let SubscriptionDeliveryMethodsKey = "delivery_methods" + +// Delivery methods keys +private let DeliveryMethodEmailKey = "email" +private let DeliveryMethodNotificationKey = "notification" + +@objcMembers public class RemoteReaderSiteInfo: NSObject { + public var feedID: NSNumber? + public var feedURL: String? + public var isFollowing: Bool = false + public var isJetpack: Bool = false + public var isPrivate: Bool = false + public var isVisible: Bool = false + public var organizationID: NSNumber? + public var postCount: NSNumber? + public var siteBlavatar: String? + public var siteDescription: String? + public var siteID: NSNumber? + public var siteName: String? + public var siteURL: String? + public var subscriberCount: NSNumber? + public var unseenCount: NSNumber? + public var postsEndpoint: String? + public var endpointPath: String? + + public var postSubscription: RemoteReaderSiteInfoSubscriptionPost? + public var emailSubscription: RemoteReaderSiteInfoSubscriptionEmail? + + public class func siteInfo(forSiteResponse response: NSDictionary, isFeed: Bool) -> RemoteReaderSiteInfo { + if isFeed { + return siteInfo(forFeedResponse: response) + } + + let siteInfo = RemoteReaderSiteInfo() + siteInfo.feedID = response.number(forKey: SiteDictionaryFeedIDKey) + siteInfo.feedURL = response.string(forKey: SiteDictionaryFeedURLKey) + siteInfo.isFollowing = response.number(forKey: SiteDictionaryFollowingKey)?.boolValue ?? false + siteInfo.isJetpack = response.number(forKey: SiteDictionaryJetpackKey)?.boolValue ?? false + siteInfo.isPrivate = response.number(forKey: SiteDictionaryPrivateKey)?.boolValue ?? false + siteInfo.isVisible = response.number(forKey: SiteDictionaryVisibleKey)?.boolValue ?? false + siteInfo.organizationID = response.number(forKey: SiteDictionaryOrganizationID) ?? 0 + siteInfo.postCount = response.number(forKey: SiteDictionaryPostCountKey) + siteInfo.siteBlavatar = response.string(forKeyPath: SiteDictionaryIconPathKey) + siteInfo.siteDescription = response.string(forKey: SiteDictionaryDescriptionKey) + siteInfo.siteID = response.number(forKey: SiteDictionaryIDKey) + siteInfo.siteName = response.string(forKey: SiteDictionaryNameKey) + siteInfo.siteURL = response.string(forKey: SiteDictionaryURLKey) + siteInfo.subscriberCount = response.number(forKey: SiteDictionarySubscriptionsKey) ?? 0 + siteInfo.unseenCount = response.number(forKey: SiteDictionaryUnseenCountKey) ?? 0 + + if (siteInfo.siteName?.count ?? 0) == 0, + let siteURLString = siteInfo.siteURL, + let siteURL = URL(string: siteURLString) { + siteInfo.siteName = siteURL.host + } + + siteInfo.endpointPath = "read/sites/\(siteInfo.siteID ?? 0)/posts/" + + if let subscription = response[SiteDictionarySubscriptionKey] as? NSDictionary { + siteInfo.postSubscription = postSubscription(forSubscription: subscription) + siteInfo.emailSubscription = emailSubscription(forSubscription: subscription) + } + + return siteInfo + } + +} + +private extension RemoteReaderSiteInfo { + class func siteInfo(forFeedResponse response: NSDictionary) -> RemoteReaderSiteInfo { + let siteInfo = RemoteReaderSiteInfo() + siteInfo.feedID = response.number(forKey: SiteDictionaryFeedIDKey) + siteInfo.feedURL = response.string(forKey: SiteDictionaryFeedURLKey) + siteInfo.isFollowing = response.number(forKey: SiteDictionaryFollowingKey)?.boolValue ?? false + siteInfo.isJetpack = false + siteInfo.isPrivate = false + siteInfo.isVisible = true + siteInfo.postCount = 0 + siteInfo.siteBlavatar = "" + siteInfo.siteDescription = "" + siteInfo.siteID = 0 + siteInfo.siteName = response.string(forKey: SiteDictionaryNameKey) + siteInfo.siteURL = response.string(forKey: SiteDictionaryURLKey) + siteInfo.subscriberCount = response.number(forKey: SiteDictionarySubscriptionsKey) ?? 0 + + if (siteInfo.siteName?.count ?? 0) == 0, + let siteURLString = siteInfo.siteURL, + let siteURL = URL(string: siteURLString) { + siteInfo.siteName = siteURL.host + } + + siteInfo.endpointPath = "read/feed/\(siteInfo.feedID ?? 0)/posts/" + + return siteInfo + } + + /// Generate an Site Info Post Subscription object + /// + /// - Parameter subscription A dictionary object for the site subscription + /// - Returns A nullable Site Info Post Subscription + class func postSubscription(forSubscription subscription: NSDictionary) -> RemoteReaderSiteInfoSubscriptionPost? { + guard subscription.wp_isValidObject() else { + return nil + } + + guard let deliveryMethod = subscription[SubscriptionDeliveryMethodsKey] as? [String: Any], + let method = deliveryMethod[DeliveryMethodNotificationKey] as? [String: Any] + else { + return nil + } + + return RemoteReaderSiteInfoSubscriptionPost(dictionary: method) + } + + /// Generate an Site Info Email Subscription object + /// + /// - Parameter subscription A dictionary object for the site subscription + /// - Returns A nullable Site Info Email Subscription + class func emailSubscription(forSubscription subscription: NSDictionary) -> RemoteReaderSiteInfoSubscriptionEmail? { + guard subscription.wp_isValidObject() else { + return nil + } + + guard let delieveryMethod = subscription[SubscriptionDeliveryMethodsKey] as? [String: Any], + let method = delieveryMethod[DeliveryMethodEmailKey] as? [String: Any] + else { + return nil + } + + return RemoteReaderSiteInfoSubscriptionEmail(dictionary: method) + } +} diff --git a/Modules/Sources/WordPressKitModels/RemoteReaderSiteInfoSubscription.swift b/Modules/Sources/WordPressKitModels/RemoteReaderSiteInfoSubscription.swift new file mode 100644 index 000000000000..34b758e93e2e --- /dev/null +++ b/Modules/Sources/WordPressKitModels/RemoteReaderSiteInfoSubscription.swift @@ -0,0 +1,30 @@ +import Foundation + +/// Mapping keys +private struct CodingKeys { + static let sendPost = "send_posts" + static let sendComments = "send_comments" + static let postDeliveryFrequency = "post_delivery_frequency" +} + +/// Site Info Post Subscription model +@objc public class RemoteReaderSiteInfoSubscriptionPost: NSObject { + @objc public var sendPosts: Bool + + @objc required public init(dictionary: [String: Any]) { + self.sendPosts = (dictionary[CodingKeys.sendPost] as? Bool) ?? false + super.init() + } +} + +/// Site Info Email Subscription model +@objc public class RemoteReaderSiteInfoSubscriptionEmail: RemoteReaderSiteInfoSubscriptionPost { + @objc public var sendComments: Bool + @objc public var postDeliveryFrequency: String + + @objc required public init(dictionary: [String: Any]) { + sendComments = (dictionary[CodingKeys.sendComments] as? Bool) ?? false + postDeliveryFrequency = (dictionary[CodingKeys.postDeliveryFrequency] as? String) ?? "" + super.init(dictionary: dictionary) + } +} diff --git a/Modules/Sources/WordPressKitModels/RemoteReaderTopic.swift b/Modules/Sources/WordPressKitModels/RemoteReaderTopic.swift new file mode 100644 index 000000000000..4b239046f5d1 --- /dev/null +++ b/Modules/Sources/WordPressKitModels/RemoteReaderTopic.swift @@ -0,0 +1,44 @@ +import Foundation +import NSObject_SafeExpectations + +@objcMembers public class RemoteReaderTopic: NSObject { + + public var isMenuItem: Bool = false + public var isRecommended: Bool + public var isSubscribed: Bool + public var path: String? + public var slug: String? + public var title: String? + public var topicDescription: String? + public var topicID: NSNumber + public var type: String? + public var owner: String? + public var organizationID: NSNumber + + /// Create `RemoteReaderTopic` with the supplied topics dictionary, ensuring expected keys are always present. + /// + /// - Parameters: + /// - topicDict: The topic `NSDictionary` to normalize. + /// - subscribed: Whether the current account subscribes to the topic. + /// - recommended: Whether the topic is recommended. + public init(dictionary topicDict: NSDictionary, subscribed: Bool, recommended: Bool) { + topicID = topicDict.number(forKey: topicDictionaryIDKey) ?? 0 + owner = topicDict.string(forKey: topicDictionaryOwnerKey) + path = topicDict.string(forKey: topicDictionaryURLKey)?.lowercased() + slug = topicDict.string(forKey: topicDictionarySlugKey) + title = topicDict.string(forKey: topicDictionaryDisplayNameKey) ?? topicDict.string(forKey: topicDictionaryTitleKey) + type = topicDict.string(forKey: topicDictionaryTypeKey) + organizationID = topicDict.number(forKeyPath: topicDictionaryOrganizationIDKey) ?? 0 + isSubscribed = subscribed + isRecommended = recommended + } +} + +private let topicDictionaryIDKey = "ID" +private let topicDictionaryOrganizationIDKey = "organization_id" +private let topicDictionaryOwnerKey = "owner" +private let topicDictionarySlugKey = "slug" +private let topicDictionaryTitleKey = "title" +private let topicDictionaryTypeKey = "type" +private let topicDictionaryDisplayNameKey = "display_name" +private let topicDictionaryURLKey = "URL" diff --git a/Modules/Sources/WordPressKitModels/RemoteUser+Likes.swift b/Modules/Sources/WordPressKitModels/RemoteUser+Likes.swift new file mode 100644 index 000000000000..299fb6edf152 --- /dev/null +++ b/Modules/Sources/WordPressKitModels/RemoteUser+Likes.swift @@ -0,0 +1,63 @@ +import Foundation + +@objc public class RemoteLikeUser: RemoteUser { + @objc public var bio: String? + @objc public var dateLiked: String? + @objc public var likedSiteID: NSNumber? + @objc public var likedPostID: NSNumber? + @objc public var likedCommentID: NSNumber? + @objc public var preferredBlog: RemoteLikeUserPreferredBlog? + + @objc public init(dictionary: [String: Any], postID: NSNumber, siteID: NSNumber) { + super.init() + setValuesFor(dictionary: dictionary) + likedPostID = postID + likedSiteID = siteID + } + + @objc public init(dictionary: [String: Any], commentID: NSNumber, siteID: NSNumber) { + super.init() + setValuesFor(dictionary: dictionary) + likedCommentID = commentID + likedSiteID = siteID + } + + private func setValuesFor(dictionary: [String: Any]) { + userID = dictionary["ID"] as? NSNumber + username = dictionary["login"] as? String + displayName = dictionary["name"] as? String + primaryBlogID = dictionary["site_ID"] as? NSNumber + avatarURL = dictionary["avatar_URL"] as? String + bio = dictionary["bio"] as? String + dateLiked = dictionary["date_liked"] as? String + + preferredBlog = { + if let preferredBlogDict = dictionary["preferred_blog"] as? [String: Any] { + return RemoteLikeUserPreferredBlog.init(dictionary: preferredBlogDict) + } + return nil + }() + } + +} + +@objc public class RemoteLikeUserPreferredBlog: NSObject { + @objc public var blogUrl: String + @objc public var blogName: String + @objc public var iconUrl: String + @objc public var blogID: NSNumber? + + public init(dictionary: [String: Any]) { + blogUrl = dictionary["url"] as? String ?? "" + blogName = dictionary["name"] as? String ?? "" + blogID = dictionary["id"] as? NSNumber ?? nil + + iconUrl = { + if let iconInfo = dictionary["icon"] as? [String: Any], + let iconImg = iconInfo["img"] as? String { + return iconImg + } + return "" + }() + } +} diff --git a/Modules/Sources/WordPressKitModels/RemoteUser.swift b/Modules/Sources/WordPressKitModels/RemoteUser.swift new file mode 100644 index 000000000000..e6cfebdbe175 --- /dev/null +++ b/Modules/Sources/WordPressKitModels/RemoteUser.swift @@ -0,0 +1,13 @@ +import Foundation + +@objcMembers public class RemoteUser: NSObject { + public var userID: NSNumber? + public var username: String? + public var email: String? + public var displayName: String? + public var primaryBlogID: NSNumber? + public var avatarURL: String? + public var dateCreated: Date? + public var emailVerified: Bool = false + public var linkedUserID: NSNumber? +} diff --git a/Modules/Sources/WordPressKitModels/RemoteVideoPressVideo.swift b/Modules/Sources/WordPressKitModels/RemoteVideoPressVideo.swift new file mode 100644 index 000000000000..7613136a0c3e --- /dev/null +++ b/Modules/Sources/WordPressKitModels/RemoteVideoPressVideo.swift @@ -0,0 +1,100 @@ +import Foundation + +/// This enum matches the privacy setting constants defined in Jetpack: +/// https://github.com/Automattic/jetpack/blob/a2ccfb7978184e306211292a66ed49dcf38a517f/projects/packages/videopress/src/utility-functions.php#L13-L17 +@objc public enum VideoPressPrivacySetting: Int, Encodable { + case isPublic = 0 + case isPrivate = 1 + case siteDefault = 2 +} + +@objcMembers public class RemoteVideoPressVideo: NSObject, Encodable { + + /// The following properties match the response parameters from the `videos` endpoint: + /// https://developer.wordpress.com/docs/api/1.1/get/videos/%24guid/ + /// + /// However, it's missing the following parameters that could be added in the future if needed: + /// - files + /// - file_url_base + /// - upload_date + /// - files_status + /// - subtitles + public var id: String + public var title: String? + public var videoDescription: String? + public var width: Int? + public var height: Int? + public var duration: Int? + public var displayEmbed: Bool? + public var allowDownload: Bool? + public var rating: String? + public var privacySetting: VideoPressPrivacySetting = .siteDefault + public var posterURL: URL? + public var originalURL: URL? + public var watermarkURL: URL? + public var bgColor: String? + public var blogId: Int? + public var postId: Int? + public var finished: Bool? + + public var token: String? + + enum CodingKeys: String, CodingKey { + case id, title, videoDescription = "description", width, height, duration, displayEmbed, allowDownload, rating, privacySetting, posterURL, originalURL, watermarkURL, bgColor, blogId, postId, finished, token + } + + public init(dictionary metadataDict: NSDictionary, id: String) { + self.id = id + + title = metadataDict.string(forKey: "title") + videoDescription = metadataDict.string(forKey: "description") + width = metadataDict.number(forKey: "width")?.intValue + height = metadataDict.number(forKey: "height")?.intValue + duration = metadataDict.number(forKey: "duration")?.intValue + displayEmbed = metadataDict.object(forKey: "display_embed") as? Bool + allowDownload = metadataDict.object(forKey: "allow_download") as? Bool + rating = metadataDict.string(forKey: "rating") + if let privacySettingValue = metadataDict.number(forKey: "privacy_setting")?.intValue, let privacySettingEnum = VideoPressPrivacySetting.init(rawValue: privacySettingValue) { + privacySetting = privacySettingEnum + } + if let poster = metadataDict.string(forKey: "poster") { + posterURL = URL(string: poster) + } + if let original = metadataDict.string(forKey: "original") { + originalURL = URL(string: original) + } + if let watermark = metadataDict.string(forKey: "watermark") { + watermarkURL = URL(string: watermark) + } + bgColor = metadataDict.string(forKey: "bg_color") + blogId = metadataDict.number(forKey: "blog_id")?.intValue + postId = metadataDict.number(forKey: "post_id")?.intValue + finished = metadataDict.object(forKey: "finished") as? Bool + } + + /// Returns the specified URL adding the token as a query parameter, which is required to play private videos. + /// - Parameters: + /// - url: URL to include the token. + /// + /// - Returns: The specified URL with the token as a query parameter. It will return `nil` if the token is not present. + @objc(getURLWithToken:) + public func getURLWithToken(url: URL) -> URL? { + guard let token, var urlComponents = URLComponents(url: url, resolvingAgainstBaseURL: true) else { + return nil + } + let metadataTokenParam = URLQueryItem(name: "metadata_token", value: token) + urlComponents.queryItems = (urlComponents.queryItems ?? []) + [metadataTokenParam] + return urlComponents.url + } + + public func asDictionary() -> [String: Any] { + guard + let data = try? JSONEncoder().encode(self), + let dictionary = try? JSONSerialization.jsonObject(with: data, options: .allowFragments) as? [String: Any] + else { + assertionFailure("Encoding of RemoteVideoPressVideo failed") + return [String: Any]() + } + return dictionary + } +} diff --git a/Modules/Sources/WordPressKitObjC/AccountServiceRemoteREST.m b/Modules/Sources/WordPressKitObjC/AccountServiceRemoteREST.m new file mode 100644 index 000000000000..fe0d75aefc18 --- /dev/null +++ b/Modules/Sources/WordPressKitObjC/AccountServiceRemoteREST.m @@ -0,0 +1,429 @@ +#import "AccountServiceRemoteREST.h" +#import "WPMapFilterReduce.h" + +@import WordPressKitModels; +@import NSObject_SafeExpectations; + +static NSString * const UserDictionaryIDKey = @"ID"; +static NSString * const UserDictionaryUsernameKey = @"username"; +static NSString * const UserDictionaryEmailKey = @"email"; +static NSString * const UserDictionaryDisplaynameKey = @"display_name"; +static NSString * const UserDictionaryPrimaryBlogKey = @"primary_blog"; +static NSString * const UserDictionaryAvatarURLKey = @"avatar_URL"; +static NSString * const UserDictionaryDateKey = @"date"; +static NSString * const UserDictionaryEmailVerifiedKey = @"email_verified"; + +MagicLinkParameter const MagicLinkParameterFlow = @"flow"; +MagicLinkParameter const MagicLinkParameterSource = @"source"; + +MagicLinkSource const MagicLinkSourceDefault = @"default"; +MagicLinkSource const MagicLinkSourceJetpackConnect = @"jetpack"; + +MagicLinkFlow const MagicLinkFlowLogin = @"login"; +MagicLinkFlow const MagicLinkFlowSignup = @"signup"; + +@interface AccountServiceRemoteREST () + +@end + +@implementation AccountServiceRemoteREST + +- (void)getBlogs:(BOOL)filterJetpackSites + success:(void (^)(NSArray *))success + failure:(void (^)(NSError *))failure +{ + if (filterJetpackSites) { + [self getBlogsWithParameters:@{@"filters": @"jetpack"} success:success failure:failure]; + } else { + [self getBlogsWithSuccess:success failure:failure]; + } +} + +- (void)getBlogsWithSuccess:(void (^)(NSArray *))success + failure:(void (^)(NSError *))failure +{ + [self getBlogsWithParameters:nil success:success failure:failure]; +} + +- (void)getVisibleBlogsWithSuccess:(void (^)(NSArray *))success + failure:(void (^)(NSError *))failure +{ + [self getBlogsWithParameters:@{@"site_visibility": @"visible"} success:success failure:failure]; +} + +- (void)getAccountDetailsWithSuccess:(void (^)(RemoteUser *remoteUser))success + failure:(void (^)(NSError *error))failure +{ + NSString *requestUrl = [self pathForEndpoint:@"me" + withVersion:WordPressComRESTAPIVersion_1_1]; + + [self.wordPressComRESTAPI get:requestUrl + parameters:nil + success:^(id responseObject, NSHTTPURLResponse *httpResponse) { + if (!success) { + return; + } + RemoteUser *remoteUser = [self remoteUserFromDictionary:responseObject]; + success(remoteUser); + } + failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + +- (void)updateBlogsVisibility:(NSDictionary *)blogs + success:(void (^)(void))success + failure:(void (^)(NSError *))failure +{ + NSParameterAssert([blogs isKindOfClass:[NSDictionary class]]); + + /* + The `POST me/sites` endpoint expects it's input in a format like: + @{ + @"sites": @[ + @"1234": { + @"visible": @YES + }, + @"2345": { + @"visible": @NO + }, + ] + } + */ + NSMutableDictionary *sites = [NSMutableDictionary dictionaryWithCapacity:blogs.count]; + [blogs enumerateKeysAndObjectsUsingBlock:^(id key, id obj, BOOL *stop) { + NSParameterAssert([key isKindOfClass:[NSNumber class]]); + NSParameterAssert([obj isKindOfClass:[NSNumber class]]); + /* + Blog IDs are pased as strings because JSON dictionaries can't take + non-string keys. If you try, you get a NSInvalidArgumentException + */ + NSString *blogID = [key stringValue]; + sites[blogID] = @{ @"visible": obj }; + }]; + + NSDictionary *parameters = @{ + @"sites": sites + }; + NSString *path = [self pathForEndpoint:@"me/sites" + withVersion:WordPressComRESTAPIVersion_1_1]; + [self.wordPressComRESTAPI post:path + parameters:parameters + success:^(id responseObject, NSHTTPURLResponse *httpResponse) { + if (success) { + success(); + } + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + +- (void)isPasswordlessAccount:(NSString *)identifier success:(void (^)(BOOL passwordless))success failure:(void (^)(NSError *error))failure +{ + NSString *encodedIdentifier = [identifier stringByAddingPercentEncodingWithAllowedCharacters:NSCharacterSet.URLPathRFC3986AllowedCharacterSet]; + + NSString *path = [self pathForEndpoint:[NSString stringWithFormat:@"users/%@/auth-options", encodedIdentifier] + withVersion:WordPressComRESTAPIVersion_1_1]; + [self.wordPressComRESTAPI get:path + parameters:nil + success:^(id responseObject, NSHTTPURLResponse *httpResponse) { + if (!success) { + return; + } + NSDictionary *dict = (NSDictionary *)responseObject; + BOOL passwordless = [[dict numberForKey:@"passwordless"] boolValue]; + success(passwordless); + + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + +- (void)isEmailAvailable:(NSString *)email success:(void (^)(BOOL available))success failure:(void (^)(NSError *error))failure +{ + static NSString * const errorEmailAddressInvalid = @"invalid"; + static NSString * const errorEmailAddressTaken = @"taken"; + + [self.wordPressComRESTAPI get:@"is-available/email" + parameters:@{ @"q": email, @"format": @"json"} + success:^(id responseObject, NSHTTPURLResponse *httpResponse) { + if ([responseObject isKindOfClass:[NSDictionary class]]) { + NSString *error = [responseObject objectForKey:@"error"]; + NSString *message = [responseObject objectForKey:@"message"]; + + if (error != NULL) { + if ([error isEqualToString:errorEmailAddressTaken]) { + // While this is informed as an error by the endpoint, for the purpose of this method + // it's a success case. We just need to inform the caller that the email is not + // available. + success(false); + } else if ([error isEqualToString:errorEmailAddressInvalid]) { + NSError* error = [[NSError alloc] initWithDomain:AccountServiceRemoteErrorDomain + code:AccountServiceRemoteEmailAddressInvalid + userInfo:@{ + @"response": responseObject, + NSLocalizedDescriptionKey: message, + }]; + if (failure) { + failure(error); + } + } else { + NSError* error = [[NSError alloc] initWithDomain:AccountServiceRemoteErrorDomain + code:AccountServiceRemoteEmailAddressCheckError + userInfo:@{ + @"response": responseObject, + NSLocalizedDescriptionKey: message, + }]; + if (failure) { + failure(error); + } + } + + return; + } + + if (success) { + BOOL available = [[responseObject numberForKey:@"available"] boolValue]; + success(available); + } + } else { + NSError* error = [[NSError alloc] initWithDomain:AccountServiceRemoteErrorDomain + code:AccountServiceRemoteCantReadServerResponse + userInfo:@{@"response": responseObject}]; + + if (failure) { + failure(error); + } + } + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + +- (void)isUsernameAvailable:(NSString *)username + success:(void (^)(BOOL available))success + failure:(void (^)(NSError *error))failure +{ + [self.wordPressComRESTAPI get:@"is-available/username" + parameters:@{ @"q": username, @"format": @"json"} + success:^(id responseObject, NSHTTPURLResponse *httpResponse) { + if (!success) { + return; + } + + // currently the endpoint will not respond with available=false + // but it could one day, and this should still work in that case + BOOL available = NO; + if ([responseObject isKindOfClass:[NSDictionary class]]) { + NSDictionary *dict = (NSDictionary *)responseObject; + available = [[dict numberForKey:@"available"] boolValue]; + } + success(available); + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + // If the username is not available (has already been used) + // the endpoint will reply with a 200 status code but describe + // an error. This causes a JSON error, which we can test for here. + if (httpResponse.statusCode == 200 && [error.description containsString:@"JSON"]) { + if (success) { + success(true); + } + } else if (failure) { + failure(error); + } + }]; +} + +- (void)requestWPComAuthLinkForEmail:(NSString *)email + clientID:(NSString *)clientID + clientSecret:(NSString *)clientSecret + source:(MagicLinkSource)source + wpcomScheme:(NSString *)scheme + createAccountIfNotFound:(BOOL)createAccountIfNotFound + success:(void (^)(void))success + failure:(void (^)(NSError *error))failure +{ + NSString *path = [self pathForEndpoint:@"auth/send-login-email" + withVersion:WordPressComRESTAPIVersion_1_3]; + + NSDictionary *extraParams = @{ + MagicLinkParameterFlow: MagicLinkFlowLogin, + MagicLinkParameterSource: source, + @"create_account": createAccountIfNotFound ? @"true" : @"false" + }; + + [self requestWPComMagicLinkForEmail:email + path:path + clientID:clientID + clientSecret:clientSecret + extraParams:extraParams + wpcomScheme:scheme + success:success + failure:failure]; +} + +- (void)requestWPComAuthLinkForEmail:(NSString *)email + clientID:(NSString *)clientID + clientSecret:(NSString *)clientSecret + source:(MagicLinkSource)source + wpcomScheme:(NSString *)scheme + success:(void (^)(void))success + failure:(void (^)(NSError *error))failure +{ + [self requestWPComAuthLinkForEmail:email + clientID:clientID + clientSecret:clientSecret + source:source + wpcomScheme:scheme + createAccountIfNotFound:NO + success:success + failure:failure]; +} + +- (void)requestWPComSignupLinkForEmail:(NSString *)email + clientID:(NSString *)clientID + clientSecret:(NSString *)clientSecret + wpcomScheme:(NSString *)scheme + success:(void (^)(void))success + failure:(void (^)(NSError *error))failure +{ + + NSString *path = [self pathForEndpoint:@"auth/send-signup-email" + withVersion:WordPressComRESTAPIVersion_1_1]; + + NSDictionary *extraParams = @{ + @"signup_flow_name": @"mobile-ios", + MagicLinkParameterFlow: MagicLinkFlowSignup + }; + + [self requestWPComMagicLinkForEmail:email + path:path + clientID:clientID + clientSecret:clientSecret + extraParams:extraParams + wpcomScheme:scheme + success:success + failure:failure]; +} + +- (void)requestWPComMagicLinkForEmail:(NSString *)email + path:(NSString *)path + clientID:(NSString *)clientID + clientSecret:(NSString *)clientSecret + extraParams:(nullable NSDictionary *)extraParams + wpcomScheme:(NSString *)scheme + success:(void (^)(void))success + failure:(void (^)(NSError *error))failure +{ + NSAssert([email length] > 0, @"Needs an email address."); + + NSMutableDictionary *params = [NSMutableDictionary dictionaryWithDictionary:@{ + @"email": email, + @"client_id": clientID, + @"client_secret": clientSecret + }]; + + if (![@"wordpress" isEqualToString:scheme]) { + [params setObject:scheme forKey:@"scheme"]; + } + + if (extraParams != nil) { + [params addEntriesFromDictionary:extraParams]; + } + + [self.wordPressComRESTAPI post:path + parameters:[NSDictionary dictionaryWithDictionary:params] + success:^(id responseObject, NSHTTPURLResponse *httpResponse) { + if (success) { + success(); + } + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + +- (void)requestVerificationEmailWithSucccess:(void (^)(void))success + failure:(void (^)(NSError *))failure +{ + NSString *path = [self pathForEndpoint:@"me/send-verification-email" + withVersion:WordPressComRESTAPIVersion_1_1]; + + [self.wordPressComRESTAPI post:path parameters:nil success:^(id _Nonnull responseObject, NSHTTPURLResponse * _Nullable httpResponse) { + if (success) { + success(); + } + } failure:^(NSError * _Nonnull error, NSHTTPURLResponse * _Nullable response) { + if (failure) { + failure(error); + } + }]; +} + +#pragma mark - Private Methods + +- (void)getBlogsWithParameters:(NSDictionary *)parameters + success:(void (^)(NSArray *))success + failure:(void (^)(NSError *))failure +{ + NSString *requestUrl = [self pathForEndpoint:@"me/sites" + withVersion:WordPressComRESTAPIVersion_1_2]; + [self.wordPressComRESTAPI get:requestUrl + parameters:parameters + success:^(id responseObject, NSHTTPURLResponse *httpResponse) { + if (success) { + success([self remoteBlogsFromJSONArray:responseObject[@"sites"]]); + } + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + +- (RemoteUser *)remoteUserFromDictionary:(NSDictionary *)dictionary +{ + RemoteUser *remoteUser = [RemoteUser new]; + remoteUser.userID = [dictionary numberForKey:UserDictionaryIDKey]; + remoteUser.username = [dictionary stringForKey:UserDictionaryUsernameKey]; + remoteUser.email = [dictionary stringForKey:UserDictionaryEmailKey]; + remoteUser.displayName = [dictionary stringForKey:UserDictionaryDisplaynameKey]; + remoteUser.primaryBlogID = [dictionary numberForKey:UserDictionaryPrimaryBlogKey]; + remoteUser.avatarURL = [dictionary stringForKey:UserDictionaryAvatarURLKey]; + remoteUser.dateCreated = [NSDate wpkit_dateWithISO8601String:[dictionary stringForKey:UserDictionaryDateKey]]; + remoteUser.emailVerified = [[dictionary numberForKey:UserDictionaryEmailVerifiedKey] boolValue]; + + return remoteUser; +} + +- (NSArray *)remoteBlogsFromJSONArray:(NSArray *)jsonBlogs +{ + NSArray *blogs = jsonBlogs; + return [[blogs wpkit_map:^id(NSDictionary *jsonBlog) { + return [[RemoteBlog alloc] initWithJSONDictionary:jsonBlog]; + }] wpkit_filter:^BOOL(RemoteBlog *blog) { + // Exclude deleted sites from query result, since the app does not handle deleted sites properly. + // I tried to use query arguments `site_visibility=visible` and `site_activity=active`, but neither excludes + // deleted sites. + if (blog.isDeleted) { + return false; + } + + // Exclude sites that are connected via Jetpack, but without an active Jetpack connection. + if (blog.jetpackConnection && !blog.jetpack) { + return false; + } + + return true; + }]; +} + +@end diff --git a/Modules/Sources/WordPressKitObjC/BlogServiceRemoteREST.m b/Modules/Sources/WordPressKitObjC/BlogServiceRemoteREST.m new file mode 100644 index 000000000000..6670ec55862b --- /dev/null +++ b/Modules/Sources/WordPressKitObjC/BlogServiceRemoteREST.m @@ -0,0 +1,507 @@ +#import +#import "BlogServiceRemoteREST.h" +#import "NSMutableDictionary+Helpers.h" +#import "RemotePostType.h" +#import "WPMapFilterReduce.h" +#import "WPKitLogging.h" + +@import WordPressKitModels; +@import NSObject_SafeExpectations; + +#pragma mark - Parsing Keys +static NSString * const RemoteBlogNameKey = @"name"; +static NSString * const RemoteBlogTaglineKey = @"description"; +static NSString * const RemoteBlogPrivacyKey = @"blog_public"; +static NSString * const RemoteBlogLanguageKey = @"lang_id"; +static NSString * const RemoteBlogIconKey = @"site_icon"; +static NSString * const RemoteBlogGMTOffsetKey = @"gmt_offset"; +static NSString * const RemoteBlogTimezoneStringKey = @"timezone_string"; + +static NSString * const RemoteBlogSettingsKey = @"settings"; +static NSString * const RemoteBlogDefaultCategoryKey = @"default_category"; +static NSString * const RemoteBlogDefaultPostFormatKey = @"default_post_format"; +static NSString * const RemoteBlogDateFormatKey = @"date_format"; +static NSString * const RemoteBlogTimeFormatKey = @"time_format"; +static NSString * const RemoteBlogStartOfWeekKey = @"start_of_week"; +static NSString * const RemoteBlogPostsPerPageKey = @"posts_per_page"; +static NSString * const RemoteBlogCommentsAllowedKey = @"default_comment_status"; +static NSString * const RemoteBlogCommentsBlocklistKeys = @"blacklist_keys"; +static NSString * const RemoteBlogCommentsCloseAutomaticallyKey = @"close_comments_for_old_posts"; +static NSString * const RemoteBlogCommentsCloseAutomaticallyAfterDaysKey = @"close_comments_days_old"; +static NSString * const RemoteBlogCommentsKnownUsersAllowlistKey = @"comment_whitelist"; +static NSString * const RemoteBlogCommentsMaxLinksKey = @"comment_max_links"; +static NSString * const RemoteBlogCommentsModerationKeys = @"moderation_keys"; +static NSString * const RemoteBlogCommentsPagingEnabledKey = @"page_comments"; +static NSString * const RemoteBlogCommentsPageSizeKey = @"comments_per_page"; +static NSString * const RemoteBlogCommentsRequireModerationKey = @"comment_moderation"; +static NSString * const RemoteBlogCommentsRequireNameAndEmailKey = @"require_name_email"; +static NSString * const RemoteBlogCommentsRequireRegistrationKey = @"comment_registration"; +static NSString * const RemoteBlogCommentsSortOrderKey = @"comment_order"; +static NSString * const RemoteBlogCommentsThreadingEnabledKey = @"thread_comments"; +static NSString * const RemoteBlogCommentsThreadingDepthKey = @"thread_comments_depth"; +static NSString * const RemoteBlogCommentsPingbackOutboundKey = @"default_pingback_flag"; +static NSString * const RemoteBlogCommentsPingbackInboundKey = @"default_ping_status"; +static NSString * const RemoteBlogRelatedPostsAllowedKey = @"jetpack_relatedposts_allowed"; +static NSString * const RemoteBlogRelatedPostsEnabledKey = @"jetpack_relatedposts_enabled"; +static NSString * const RemoteBlogRelatedPostsShowHeadlineKey = @"jetpack_relatedposts_show_headline"; +static NSString * const RemoteBlogRelatedPostsShowThumbnailsKey = @"jetpack_relatedposts_show_thumbnails"; +static NSString * const RemoteBlogAmpSupportedKey = @"amp_is_supported"; +static NSString * const RemoteBlogAmpEnabledKey = @"amp_is_enabled"; + +static NSString * const RemoteBlogSharingButtonStyle = @"sharing_button_style"; +static NSString * const RemoteBlogSharingLabel = @"sharing_label"; +static NSString * const RemoteBlogSharingTwitterName = @"twitter_via"; +static NSString * const RemoteBlogSharingCommentLikesEnabled = @"jetpack_comment_likes_enabled"; +static NSString * const RemoteBlogSharingDisabledLikes = @"disabled_likes"; +static NSString * const RemoteBlogSharingDisabledReblogs = @"disabled_reblogs"; + +static NSString * const RemotePostTypesKey = @"post_types"; +static NSString * const RemotePostTypeNameKey = @"name"; +static NSString * const RemotePostTypeLabelKey = @"label"; +static NSString * const RemotePostTypeQueryableKey = @"api_queryable"; + +#pragma mark - Keys used for Update Calls +// Note: Only god knows why these don't match the "Parsing Keys" +static NSString * const RemoteBlogNameForUpdateKey = @"blogname"; +static NSString * const RemoteBlogTaglineForUpdateKey = @"blogdescription"; + +#pragma mark - Defaults +static NSString * const RemoteBlogDefaultPostFormat = @"standard"; +static NSInteger const RemoteBlogUncategorizedCategory = 1; + + + +@implementation BlogServiceRemoteREST + +- (void)getAllAuthorsWithSuccess:(UsersHandler)success + failure:(void (^)(NSError *error))failure +{ + [self getAllAuthorsWithRemoteUsers:nil + offset:nil + success:success + failure:failure]; +} + +/** + This method is called recursively to fetch all authors. + The success block is called whenever the response users array is nil or empty. + + @param remoteUsers The loaded remote users + @param offset The first n users to be skipped in the returned array + @param success The block that will be executed on success + @param failure The block that will be executed on failure + */ +- (void)getAllAuthorsWithRemoteUsers:(NSMutableArray *)remoteUsers + offset:(NSNumber *)offset + success:(UsersHandler)success + failure:(void (^)(NSError *error))failure +{ + NSMutableDictionary *parameters = [@{ @"authors_only":@(YES), + @"number": @(100) + } mutableCopy]; + + if ([offset wp_isValidObject]) { + parameters[@"offset"] = offset.stringValue; + } + + NSString *path = [self pathForUsers]; + NSString *requestUrl = [self pathForEndpoint:path + withVersion:WordPressComRESTAPIVersion_1_1]; + + [self.wordPressComRESTAPI get:requestUrl + parameters:parameters + success:^(id responseObject, NSHTTPURLResponse *httpResponse) { + if (success) { + NSArray *responseUsers = responseObject[@"users"]; + + NSMutableArray *users = [remoteUsers wp_isValidObject] ? [remoteUsers mutableCopy] : [NSMutableArray array]; + + if (![responseUsers wp_isValidObject] || responseUsers.count == 0) { + success([users copy]); + } else { + [users addObjectsFromArray:[self usersFromJSONArray:responseUsers]]; + [self getAllAuthorsWithRemoteUsers:users + offset:@(users.count) + success:success + failure:failure]; + } + } + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + +- (void)syncPostTypesWithSuccess:(PostTypesHandler)success + failure:(void (^)(NSError *error))failure +{ + NSString *path = [self pathForPostTypes]; + NSString *requestUrl = [self pathForEndpoint:path + withVersion:WordPressComRESTAPIVersion_1_1]; + NSDictionary *parameters = @{@"context": @"edit"}; + [self.wordPressComRESTAPI get:requestUrl + parameters:parameters + success:^(NSDictionary *responseObject, NSHTTPURLResponse *httpResponse) { + + NSAssert([responseObject isKindOfClass:[NSDictionary class]], @"Response should be a dictionary."); + NSArray *postTypes = [[responseObject arrayForKey:RemotePostTypesKey] wpkit_map:^id(NSDictionary *json) { + return [self remotePostTypeWithDictionary:json]; + }]; + if (!postTypes.count) { + WPKitLogError(@"Response to %@ did not include post types for site.", requestUrl); + failure(nil); + return; + } + if (success) { + success(postTypes); + } + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + +- (void)syncPostFormatsWithSuccess:(PostFormatsHandler)success + failure:(void (^)(NSError *))failure +{ + NSString *path = [self pathForPostFormats]; + NSString *requestUrl = [self pathForEndpoint:path + withVersion:WordPressComRESTAPIVersion_1_1]; + + [self.wordPressComRESTAPI get:requestUrl + parameters:nil + success:^(id responseObject, NSHTTPURLResponse *httpResponse) { + NSDictionary *formats = [self mapPostFormatsFromResponse:responseObject[@"formats"]]; + if (success) { + success(formats); + } + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + +- (void)syncBlogWithSuccess:(BlogDetailsHandler)success + failure:(void (^)(NSError *))failure +{ + NSString *path = [self pathForSite]; + NSString *requestUrl = [self pathForEndpoint:path + withVersion:WordPressComRESTAPIVersion_1_1]; + + [self.wordPressComRESTAPI get:requestUrl + parameters:nil + success:^(id responseObject, NSHTTPURLResponse *httpResponse) { + NSDictionary *responseDict = (NSDictionary *)responseObject; + RemoteBlog *remoteBlog = [[RemoteBlog alloc] initWithJSONDictionary:responseDict]; + if (success) { + success(remoteBlog); + } + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + +- (void)syncBlogSettingsWithSuccess:(SettingsHandler)success + failure:(void (^)(NSError *error))failure +{ + NSString *path = [self pathForSettings]; + NSString *requestUrl = [self pathForEndpoint:path withVersion:WordPressComRESTAPIVersion_1_1]; + + [self.wordPressComRESTAPI get:requestUrl + parameters:nil + success:^(id responseObject, NSHTTPURLResponse *httpResponse) { + if (![responseObject isKindOfClass:[NSDictionary class]]){ + if (failure) { + failure(nil); + } + return; + } + RemoteBlogSettings *remoteSettings = [self remoteBlogSettingFromJSONDictionary:responseObject]; + if (success) { + success(remoteSettings); + } + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + +- (void)updateBlogSettings:(RemoteBlogSettings *)settings + success:(SuccessHandler)success + failure:(void (^)(NSError *error))failure; +{ + NSParameterAssert(settings); + + NSDictionary *parameters = [self remoteSettingsToDictionary:settings]; + NSString *path = [NSString stringWithFormat:@"sites/%@/settings?context=edit", self.siteID]; + NSString *requestUrl = [self pathForEndpoint:path withVersion:WordPressComRESTAPIVersion_1_1]; + + [self.wordPressComRESTAPI post:requestUrl + parameters:parameters + success:^(NSDictionary *responseDict, NSHTTPURLResponse *httpResponse) { + if (![responseDict isKindOfClass:[NSDictionary class]]) { + if (failure) { + failure(nil); + } + return; + } + if (!responseDict[@"updated"]) { + if (failure) { + failure(nil); + } + } else if (success) { + success(); + } + } + failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + +- (void)fetchSiteInfoForAddress:(NSString *)siteAddress + success:(void(^)(NSDictionary *siteInfoDict))success + failure:(void (^)(NSError *error))failure +{ + NSString *path = [NSString stringWithFormat:@"sites/%@", siteAddress]; + NSString *requestUrl = [self pathForEndpoint:path + withVersion:WordPressComRESTAPIVersion_1_1]; + + [self.wordPressComRESTAPI get:requestUrl + parameters:nil + success:^(id responseObject, NSHTTPURLResponse *httpResponse) { + if (success) { + success((NSDictionary *)responseObject); + return; + } + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + +- (void)fetchUnauthenticatedSiteInfoForAddress:(NSString *)siteAddress + success:(void(^)(NSDictionary *siteInfoDict))success + failure:(void (^)(NSError *error))failure +{ + NSString *path = [self pathForEndpoint:@"connect/site-info" withVersion:WordPressComRESTAPIVersion_1_1]; + NSURL *siteURL = [NSURL URLWithString:siteAddress]; + + [self.wordPressComRESTAPI get:path + parameters:@{ @"url": siteURL.absoluteString } + success:^(id responseObject, NSHTTPURLResponse *httpResponse) { + if (success) { + success((NSDictionary *)responseObject); + } + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if(failure) { + failure(error); + } + }]; +} + +#pragma mark - API paths + +- (NSString *)pathForUsers +{ + return [NSString stringWithFormat:@"sites/%@/users", self.siteID]; +} + +- (NSString *)pathForSite +{ + return [NSString stringWithFormat:@"sites/%@", self.siteID]; +} + +- (NSString *)pathForPostTypes +{ + return [NSString stringWithFormat:@"sites/%@/post-types", self.siteID]; +} + +- (NSString *)pathForPostFormats +{ + return [NSString stringWithFormat:@"sites/%@/post-formats", self.siteID]; +} + +- (NSString *)pathForSettings +{ + return [NSString stringWithFormat:@"sites/%@/settings", self.siteID]; +} + + +#pragma mark - Mapping methods + +- (NSArray *)usersFromJSONArray:(NSArray *)jsonUsers +{ + return [jsonUsers wpkit_map:^RemoteUser *(NSDictionary *jsonUser) { + return [self userFromJSONDictionary:jsonUser]; + }]; +} + +- (RemoteUser *)userFromJSONDictionary:(NSDictionary *)jsonUser +{ + RemoteUser *user = [RemoteUser new]; + user.userID = jsonUser[@"ID"]; + user.username = jsonUser[@"login"]; + user.email = jsonUser[@"email"]; + user.displayName = jsonUser[@"name"]; + user.primaryBlogID = jsonUser[@"site_ID"]; + user.avatarURL = jsonUser[@"avatar_URL"]; + user.linkedUserID = jsonUser[@"linked_user_ID"]; + return user; +} + +- (NSDictionary *)mapPostFormatsFromResponse:(id)response +{ + if ([response isKindOfClass:[NSDictionary class]]) { + return response; + } else { + return @{}; + } +} + +- (RemotePostType *)remotePostTypeWithDictionary:(NSDictionary *)json +{ + RemotePostType *postType = [[RemotePostType alloc] init]; + postType.name = [json stringForKey:RemotePostTypeNameKey]; + postType.label = [json stringForKey:RemotePostTypeLabelKey]; + postType.apiQueryable = [json numberForKey:RemotePostTypeQueryableKey]; + return postType; +} + +- (RemoteBlogSettings *)remoteBlogSettingFromJSONDictionary:(NSDictionary *)json +{ + NSAssert([json isKindOfClass:[NSDictionary class]], @"Invalid Settings Kind"); + + RemoteBlogSettings *settings = [RemoteBlogSettings new]; + NSDictionary *rawSettings = [json dictionaryForKey:RemoteBlogSettingsKey]; + + // General + settings.name = [json stringForKey:RemoteBlogNameKey]; + settings.tagline = [json stringForKey:RemoteBlogTaglineKey]; + settings.privacy = [rawSettings numberForKey:RemoteBlogPrivacyKey]; + settings.languageID = [rawSettings numberForKey:RemoteBlogLanguageKey]; + settings.iconMediaID = [rawSettings numberForKey:RemoteBlogIconKey]; + settings.gmtOffset = [rawSettings numberForKey:RemoteBlogGMTOffsetKey]; + settings.timezoneString = [rawSettings stringForKey:RemoteBlogTimezoneStringKey]; + + // Writing + settings.defaultCategoryID = [rawSettings numberForKey:RemoteBlogDefaultCategoryKey] ?: @(RemoteBlogUncategorizedCategory); + + // Note: the backend might send '0' as a number, OR a string value. Ref. Issue #4187 + if ([[rawSettings numberForKey:RemoteBlogDefaultPostFormatKey] isEqualToNumber:@(0)] || + [[rawSettings stringForKey:RemoteBlogDefaultPostFormatKey] isEqualToString:@"0"]) + { + settings.defaultPostFormat = RemoteBlogDefaultPostFormat; + } else { + settings.defaultPostFormat = [rawSettings stringForKey:RemoteBlogDefaultPostFormatKey]; + } + settings.dateFormat = [rawSettings stringForKey:RemoteBlogDateFormatKey]; + settings.timeFormat = [rawSettings stringForKey:RemoteBlogTimeFormatKey]; + settings.startOfWeek = [rawSettings stringForKey:RemoteBlogStartOfWeekKey]; + settings.postsPerPage = [rawSettings numberForKey:RemoteBlogPostsPerPageKey]; + + // Discussion + settings.commentsAllowed = [rawSettings numberForKey:RemoteBlogCommentsAllowedKey]; + settings.commentsBlocklistKeys = [rawSettings stringForKey:RemoteBlogCommentsBlocklistKeys]; + settings.commentsCloseAutomatically = [rawSettings numberForKey:RemoteBlogCommentsCloseAutomaticallyKey]; + settings.commentsCloseAutomaticallyAfterDays = [rawSettings numberForKey:RemoteBlogCommentsCloseAutomaticallyAfterDaysKey]; + settings.commentsFromKnownUsersAllowlisted = [rawSettings numberForKey:RemoteBlogCommentsKnownUsersAllowlistKey]; + settings.commentsMaximumLinks = [rawSettings numberForKey:RemoteBlogCommentsMaxLinksKey]; + settings.commentsModerationKeys = [rawSettings stringForKey:RemoteBlogCommentsModerationKeys]; + settings.commentsPagingEnabled = [rawSettings numberForKey:RemoteBlogCommentsPagingEnabledKey]; + settings.commentsPageSize = [rawSettings numberForKey:RemoteBlogCommentsPageSizeKey]; + settings.commentsRequireManualModeration = [rawSettings numberForKey:RemoteBlogCommentsRequireModerationKey]; + settings.commentsRequireNameAndEmail = [rawSettings numberForKey:RemoteBlogCommentsRequireNameAndEmailKey]; + settings.commentsRequireRegistration = [rawSettings numberForKey:RemoteBlogCommentsRequireRegistrationKey]; + settings.commentsSortOrder = [rawSettings stringForKey:RemoteBlogCommentsSortOrderKey]; + settings.commentsThreadingEnabled = [rawSettings numberForKey:RemoteBlogCommentsThreadingEnabledKey]; + settings.commentsThreadingDepth = [rawSettings numberForKey:RemoteBlogCommentsThreadingDepthKey]; + settings.pingbackOutboundEnabled = [rawSettings numberForKey:RemoteBlogCommentsPingbackOutboundKey]; + settings.pingbackInboundEnabled = [rawSettings numberForKey:RemoteBlogCommentsPingbackInboundKey]; + + // Related Posts + settings.relatedPostsAllowed = [rawSettings numberForKey:RemoteBlogRelatedPostsAllowedKey]; + settings.relatedPostsEnabled = [rawSettings numberForKey:RemoteBlogRelatedPostsEnabledKey]; + settings.relatedPostsShowHeadline = [rawSettings numberForKey:RemoteBlogRelatedPostsShowHeadlineKey]; + settings.relatedPostsShowThumbnails = [rawSettings numberForKey:RemoteBlogRelatedPostsShowThumbnailsKey]; + + // AMP + settings.ampSupported = [rawSettings numberForKey:RemoteBlogAmpSupportedKey]; + settings.ampEnabled = [rawSettings numberForKey:RemoteBlogAmpEnabledKey]; + + // Sharing + settings.sharingButtonStyle = [rawSettings stringForKey:RemoteBlogSharingButtonStyle]; + settings.sharingLabel = [rawSettings stringForKey:RemoteBlogSharingLabel]; + settings.sharingTwitterName = [rawSettings stringForKey:RemoteBlogSharingTwitterName]; + settings.sharingCommentLikesEnabled = [rawSettings numberForKey:RemoteBlogSharingCommentLikesEnabled]; + settings.sharingDisabledLikes = [rawSettings numberForKey:RemoteBlogSharingDisabledLikes]; + settings.sharingDisabledReblogs = [rawSettings numberForKey:RemoteBlogSharingDisabledReblogs]; + + return settings; +} + +- (NSDictionary *)remoteSettingsToDictionary:(RemoteBlogSettings *)settings +{ + NSParameterAssert(settings); + NSMutableDictionary *parameters = [NSMutableDictionary dictionary]; + + [parameters setValueIfNotNil:settings.name forKey:RemoteBlogNameForUpdateKey]; + [parameters setValueIfNotNil:settings.tagline forKey:RemoteBlogTaglineForUpdateKey]; + [parameters setValueIfNotNil:settings.privacy forKey:RemoteBlogPrivacyKey]; + [parameters setValueIfNotNil:settings.languageID forKey:RemoteBlogLanguageKey]; + [parameters setValueIfNotNil:settings.iconMediaID forKey:RemoteBlogIconKey]; + [parameters setValueIfNotNil:settings.gmtOffset forKey:RemoteBlogGMTOffsetKey]; + [parameters setValueIfNotNil:settings.timezoneString forKey:RemoteBlogTimezoneStringKey]; + + [parameters setValueIfNotNil:settings.defaultCategoryID forKey:RemoteBlogDefaultCategoryKey]; + [parameters setValueIfNotNil:settings.defaultPostFormat forKey:RemoteBlogDefaultPostFormatKey]; + [parameters setValueIfNotNil:settings.dateFormat forKey:RemoteBlogDateFormatKey]; + [parameters setValueIfNotNil:settings.timeFormat forKey:RemoteBlogTimeFormatKey]; + [parameters setValueIfNotNil:settings.startOfWeek forKey:RemoteBlogStartOfWeekKey]; + [parameters setValueIfNotNil:settings.postsPerPage forKey:RemoteBlogPostsPerPageKey]; + + [parameters setValueIfNotNil:settings.commentsAllowed forKey:RemoteBlogCommentsAllowedKey]; + [parameters setValueIfNotNil:settings.commentsBlocklistKeys forKey:RemoteBlogCommentsBlocklistKeys]; + [parameters setValueIfNotNil:settings.commentsCloseAutomatically forKey:RemoteBlogCommentsCloseAutomaticallyKey]; + [parameters setValueIfNotNil:settings.commentsCloseAutomaticallyAfterDays forKey:RemoteBlogCommentsCloseAutomaticallyAfterDaysKey]; + [parameters setValueIfNotNil:settings.commentsFromKnownUsersAllowlisted forKey:RemoteBlogCommentsKnownUsersAllowlistKey]; + [parameters setValueIfNotNil:settings.commentsMaximumLinks forKey:RemoteBlogCommentsMaxLinksKey]; + [parameters setValueIfNotNil:settings.commentsModerationKeys forKey:RemoteBlogCommentsModerationKeys]; + [parameters setValueIfNotNil:settings.commentsPagingEnabled forKey:RemoteBlogCommentsPagingEnabledKey]; + [parameters setValueIfNotNil:settings.commentsPageSize forKey:RemoteBlogCommentsPageSizeKey]; + [parameters setValueIfNotNil:settings.commentsRequireManualModeration forKey:RemoteBlogCommentsRequireModerationKey]; + [parameters setValueIfNotNil:settings.commentsRequireNameAndEmail forKey:RemoteBlogCommentsRequireNameAndEmailKey]; + [parameters setValueIfNotNil:settings.commentsRequireRegistration forKey:RemoteBlogCommentsRequireRegistrationKey]; + [parameters setValueIfNotNil:settings.commentsSortOrder forKey:RemoteBlogCommentsSortOrderKey]; + [parameters setValueIfNotNil:settings.commentsThreadingEnabled forKey:RemoteBlogCommentsThreadingEnabledKey]; + [parameters setValueIfNotNil:settings.commentsThreadingDepth forKey:RemoteBlogCommentsThreadingDepthKey]; + + [parameters setValueIfNotNil:settings.pingbackOutboundEnabled forKey:RemoteBlogCommentsPingbackOutboundKey]; + [parameters setValueIfNotNil:settings.pingbackInboundEnabled forKey:RemoteBlogCommentsPingbackInboundKey]; + + [parameters setValueIfNotNil:settings.relatedPostsEnabled forKey:RemoteBlogRelatedPostsEnabledKey]; + [parameters setValueIfNotNil:settings.relatedPostsShowHeadline forKey:RemoteBlogRelatedPostsShowHeadlineKey]; + [parameters setValueIfNotNil:settings.relatedPostsShowThumbnails forKey:RemoteBlogRelatedPostsShowThumbnailsKey]; + + [parameters setValueIfNotNil:settings.ampEnabled forKey:RemoteBlogAmpEnabledKey]; + + // Sharing + [parameters setValueIfNotNil:settings.sharingButtonStyle forKey:RemoteBlogSharingButtonStyle]; + [parameters setValueIfNotNil:settings.sharingLabel forKey:RemoteBlogSharingLabel]; + [parameters setValueIfNotNil:settings.sharingTwitterName forKey:RemoteBlogSharingTwitterName]; + [parameters setValueIfNotNil:settings.sharingCommentLikesEnabled forKey:RemoteBlogSharingCommentLikesEnabled]; + [parameters setValueIfNotNil:settings.sharingDisabledLikes forKey:RemoteBlogSharingDisabledLikes]; + [parameters setValueIfNotNil:settings.sharingDisabledReblogs forKey:RemoteBlogSharingDisabledReblogs]; + + return parameters; +} + +@end diff --git a/Modules/Sources/WordPressKitObjC/BlogServiceRemoteXMLRPC.m b/Modules/Sources/WordPressKitObjC/BlogServiceRemoteXMLRPC.m new file mode 100644 index 000000000000..674d86cad0fd --- /dev/null +++ b/Modules/Sources/WordPressKitObjC/BlogServiceRemoteXMLRPC.m @@ -0,0 +1,211 @@ +#import "BlogServiceRemoteXMLRPC.h" +#import "NSMutableDictionary+Helpers.h" +#import "RemotePostType.h" +#import "WPMapFilterReduce.h" +#import "WPKitLogging.h" + +@import WordPressKitModels; +@import NSObject_SafeExpectations; + +static NSString * const RemotePostTypeNameKey = @"name"; +static NSString * const RemotePostTypeLabelKey = @"label"; +static NSString * const RemotePostTypePublicKey = @"public"; + +@implementation BlogServiceRemoteXMLRPC + +- (void)getAllAuthorsWithSuccess:(UsersHandler)success + failure:(void (^)(NSError *error))failure +{ + [self getAllAuthorsWithRemoteUsers:nil + offset:nil + success:success + failure:failure]; +} + + +/** + This method is called recursively to fetch all authors. + The success block is called whenever the response users array is nil or empty. + + @param remoteUsers The loaded remote users + @param offset The first n users to be skipped in the returned array + @param success The block that will be executed on success + @param failure The block that will be executed on failure + */ +- (void)getAllAuthorsWithRemoteUsers:(NSMutableArray *)remoteUsers + offset:(NSNumber *)offset + success:(UsersHandler)success + failure:(void (^)(NSError *error))failure +{ + NSMutableDictionary *filter = [@{ @"who":@"authors", + @"number": @(100) + } mutableCopy]; + + if ([offset wp_isValidObject]) { + filter[@"offset"] = offset.stringValue; + } + + NSArray *parameters = [self XMLRPCArgumentsWithExtra:filter]; + [self.api callMethod:@"wp.getUsers" + parameters:parameters + success:^(id responseObject, NSHTTPURLResponse *response) { + NSArray *responseUsers = [[responseObject allObjects] wpkit_map:^id(NSDictionary *xmlrpcUser) { + return [self remoteUserFromXMLRPCDictionary:xmlrpcUser]; + }]; + + NSMutableArray *users = [remoteUsers wp_isValidObject] ? [remoteUsers mutableCopy] : [NSMutableArray array]; + + if (success) { + if (![responseUsers wp_isValidObject] || responseUsers.count == 0) { + success([users copy]); + } else { + [users addObjectsFromArray:responseUsers]; + [self getAllAuthorsWithRemoteUsers:users + offset:@(users.count) + success:success + failure:failure]; + } + } + + } failure:^(NSError *error, NSHTTPURLResponse *response) { + if (failure) { + failure(error); + } + }]; +} + +- (void)syncPostTypesWithSuccess:(PostTypesHandler)success failure:(void (^)(NSError *error))failure +{ + NSArray *parameters = [self defaultXMLRPCArguments]; + [self.api callMethod:@"wp.getPostTypes" + parameters:parameters + success:^(id responseObject, NSHTTPURLResponse *response) { + + NSAssert([responseObject isKindOfClass:[NSDictionary class]], @"Response should be a dictionary."); + NSArray *postTypes = [[responseObject allObjects] wpkit_map:^id(NSDictionary *json) { + return [self remotePostTypeFromXMLRPCDictionary:json]; + }]; + if (!postTypes.count) { + WPKitLogError(@"Response to wp.getPostTypes did not include post types for site."); + failure(nil); + return; + } + if (success) { + success(postTypes); + } + } failure:^(NSError *error, NSHTTPURLResponse *response) { + WPKitLogError(@"Error syncing post types (%@): %@", response.URL, error); + + if (failure) { + failure(error); + } + }]; +} + +- (void)syncPostFormatsWithSuccess:(PostFormatsHandler)success failure:(void (^)(NSError *))failure +{ + NSDictionary *dict = @{@"show-supported": @"1"}; + NSArray *parameters = [self XMLRPCArgumentsWithExtra:dict]; + + [self.api callMethod:@"wp.getPostFormats" + parameters:parameters + success:^(id responseObject, NSHTTPURLResponse *response) { + NSAssert([responseObject isKindOfClass:[NSDictionary class]], @"Response should be a dictionary."); + + NSDictionary *postFormats = responseObject; + NSDictionary *respDict = responseObject; + if ([postFormats objectForKey:@"supported"]) { + NSMutableArray *supportedKeys; + if ([[postFormats objectForKey:@"supported"] isKindOfClass:[NSArray class]]) { + supportedKeys = [NSMutableArray arrayWithArray:[postFormats objectForKey:@"supported"]]; + } else if ([[postFormats objectForKey:@"supported"] isKindOfClass:[NSDictionary class]]) { + supportedKeys = [NSMutableArray arrayWithArray:[[postFormats objectForKey:@"supported"] allValues]]; + } + + // Standard isn't included in the list of supported formats? Maybe it will be one day? + if (![supportedKeys containsObject:@"standard"]) { + [supportedKeys addObject:@"standard"]; + } + + NSDictionary *allFormats = [postFormats objectForKey:@"all"]; + NSMutableArray *supportedValues = [NSMutableArray array]; + for (NSString *key in supportedKeys) { + [supportedValues addObject:[allFormats objectForKey:key]]; + } + respDict = [NSDictionary dictionaryWithObjects:supportedValues forKeys:supportedKeys]; + } + + if (success) { + success(respDict); + } + } failure:^(NSError *error, NSHTTPURLResponse *response) { + WPKitLogError(@"Error syncing post formats (%@): %@", response.URL, error); + + if (failure) { + failure(error); + } + }]; + +} + +- (void)syncBlogOptionsWithSuccess:(OptionsHandler)success failure:(void (^)(NSError *))failure +{ + NSArray *parameters = [self defaultXMLRPCArguments]; + [self.api callMethod:@"wp.getOptions" + parameters:parameters + success:^(id responseObject, NSHTTPURLResponse *response) { + NSAssert([responseObject isKindOfClass:[NSDictionary class]], @"Response should be a dictionary."); + + if (success) { + success(responseObject); + } + } failure:^(NSError *error, NSHTTPURLResponse *response) { + WPKitLogError(@"Error syncing blog options: %@", error); + + if (failure) { + failure(error); + } + }]; +} + +- (void)updateBlogOptionsWith:(NSDictionary *)remoteBlogOptions success:(SuccessHandler)success failure:(void (^)(NSError *))failure +{ + NSArray *parameters = [self XMLRPCArgumentsWithExtra:remoteBlogOptions]; + [self.api callMethod:@"wp.setOptions" parameters:parameters success:^(id responseObject, NSHTTPURLResponse *response) { + if (![responseObject isKindOfClass:[NSDictionary class]]) { + if (failure) { + failure(nil); + } + return; + } + if (success) { + success(); + } + } failure:^(NSError *error, NSHTTPURLResponse *response) { + WPKitLogError(@"Error updating blog options: %@", error); + if (failure) { + failure(error); + } + }]; +} + +- (RemoteUser *)remoteUserFromXMLRPCDictionary:(NSDictionary *)xmlrpcUser +{ + RemoteUser *user = [RemoteUser new]; + user.userID = [xmlrpcUser numberForKey:@"user_id"]; + user.username = [xmlrpcUser stringForKey:@"username"]; + user.displayName = [xmlrpcUser stringForKey:@"display_name"]; + user.email = [xmlrpcUser stringForKey:@"email"]; + return user; +} + +- (RemotePostType *)remotePostTypeFromXMLRPCDictionary:(NSDictionary *)json +{ + RemotePostType *postType = [[RemotePostType alloc] init]; + postType.name = [json stringForKey:RemotePostTypeNameKey]; + postType.label = [json stringForKey:RemotePostTypeLabelKey]; + postType.apiQueryable = [json numberForKey:RemotePostTypePublicKey]; + return postType; +} + +@end diff --git a/Modules/Sources/WordPressKitObjC/CommentServiceRemoteREST.m b/Modules/Sources/WordPressKitObjC/CommentServiceRemoteREST.m new file mode 100644 index 000000000000..556eb7c8c71f --- /dev/null +++ b/Modules/Sources/WordPressKitObjC/CommentServiceRemoteREST.m @@ -0,0 +1,538 @@ +#import "CommentServiceRemoteREST.h" +#import "RemoteComment.h" +#import "WPMapFilterReduce.h" + +@import WordPressKitModels; +@import NSObject_SafeExpectations; + +@implementation CommentServiceRemoteREST + +#pragma mark Public methods + +#pragma mark - Blog-centric methods + +- (void)getCommentsWithMaximumCount:(NSInteger)maximumComments + success:(void (^)(NSArray *comments))success + failure:(void (^)(NSError *error))failure +{ + [self getCommentsWithMaximumCount:maximumComments options:nil success:success failure:failure]; +} + +- (void)getCommentsWithMaximumCount:(NSInteger)maximumComments + options:(NSDictionary *)options + success:(void (^)(NSArray *posts))success + failure:(void (^)(NSError *error))failure +{ + NSString *path = [NSString stringWithFormat:@"sites/%@/comments", self.siteID]; + NSString *requestUrl = [self pathForEndpoint:path + withVersion:WordPressComRESTAPIVersion_1_1]; + + NSMutableDictionary *parameters = [NSMutableDictionary dictionaryWithDictionary:@{ + @"force": @"wpcom", // Force fetching data from shadow site on Jetpack sites + @"number": @(maximumComments) + }]; + + if (options) { + [parameters addEntriesFromDictionary:options]; + } + + NSNumber *statusFilter = [parameters numberForKey:@"status"]; + [parameters removeObjectForKey:@"status"]; + parameters[@"status"] = [self parameterForCommentStatus:statusFilter]; + + [self.wordPressComRESTAPI get:requestUrl + parameters:parameters + success:^(id responseObject, NSHTTPURLResponse *httpResponse) { + if (success) { + success([self remoteCommentsFromJSONArray:responseObject[@"comments"]]); + } + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; + +} + +- (NSString *)parameterForCommentStatus:(NSNumber *)status +{ + switch (status.intValue) { + case CommentStatusFilterUnapproved: + return @"unapproved"; + break; + case CommentStatusFilterApproved: + return @"approved"; + break; + case CommentStatusFilterTrash: + return @"trash"; + break; + case CommentStatusFilterSpam: + return @"spam"; + break; + default: + return @"all"; + break; + } +} + +- (void)getCommentWithID:(NSNumber *)commentID + success:(void (^)(RemoteComment *comment))success + failure:(void (^)(NSError * error))failure +{ + NSString *path = [NSString stringWithFormat:@"sites/%@/comments/%@", self.siteID, commentID]; + NSString *requestUrl = [self pathForEndpoint:path + withVersion:WordPressComRESTAPIVersion_1_1]; + + [self.wordPressComRESTAPI get:requestUrl + parameters:nil + success:^(id responseObject, NSHTTPURLResponse *httpResponse) { + RemoteComment *comment = [self remoteCommentFromJSONDictionary:responseObject]; + if (success) { + success(comment); + } + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + +- (void)createComment:(RemoteComment *)comment + success:(void (^)(RemoteComment *comment))success + failure:(void (^)(NSError *))failure +{ + NSString *path; + if (comment.parentID) { + path = [NSString stringWithFormat:@"sites/%@/comments/%@/replies/new", self.siteID, comment.parentID]; + } else { + path = [NSString stringWithFormat:@"sites/%@/posts/%@/replies/new", self.siteID, comment.postID]; + } + + NSString *requestUrl = [self pathForEndpoint:path + withVersion:WordPressComRESTAPIVersion_1_1]; + + NSDictionary *parameters = @{ + @"content": comment.content, + @"context": @"edit", + }; + [self.wordPressComRESTAPI post:requestUrl + parameters:parameters + success:^(id responseObject, NSHTTPURLResponse *httpResponse) { + // TODO: validate response + RemoteComment *comment = [self remoteCommentFromJSONDictionary:responseObject]; + if (success) { + success(comment); + } + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + +- (void)updateComment:(RemoteComment *)comment + success:(void (^)(RemoteComment *comment))success + failure:(void (^)(NSError *))failure +{ + NSString *path = [NSString stringWithFormat:@"sites/%@/comments/%@", self.siteID, comment.commentID]; + NSString *requestUrl = [self pathForEndpoint:path + withVersion:WordPressComRESTAPIVersion_1_1]; + + NSDictionary *parameters = @{ + @"content": comment.content, + @"author": comment.author, + @"author_email": comment.authorEmail, + @"author_url": comment.authorUrl, + @"context": @"edit", + }; + + [self.wordPressComRESTAPI post:requestUrl + parameters:parameters + success:^(id responseObject, NSHTTPURLResponse *httpResponse) { + // TODO: validate response + RemoteComment *comment = [self remoteCommentFromJSONDictionary:responseObject]; + if (success) { + success(comment); + } + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + +- (void)moderateComment:(RemoteComment *)comment + success:(void (^)(RemoteComment *))success + failure:(void (^)(NSError *))failure +{ + NSString *path = [NSString stringWithFormat:@"sites/%@/comments/%@", self.siteID, comment.commentID]; + NSString *requestUrl = [self pathForEndpoint:path + withVersion:WordPressComRESTAPIVersion_1_1]; + + NSDictionary *parameters = @{ + @"status": [self remoteStatusWithStatus:comment.status], + @"context": @"edit", + }; + [self.wordPressComRESTAPI post:requestUrl + parameters:parameters + success:^(id responseObject, NSHTTPURLResponse *httpResponse) { + // TODO: validate response + RemoteComment *comment = [self remoteCommentFromJSONDictionary:responseObject]; + if (success) { + success(comment); + } + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + +- (void)trashComment:(RemoteComment *)comment + success:(void (^)(void))success + failure:(void (^)(NSError *error))failure +{ + NSString *path = [NSString stringWithFormat:@"sites/%@/comments/%@/delete", self.siteID, comment.commentID]; + NSString *requestUrl = [self pathForEndpoint:path + withVersion:WordPressComRESTAPIVersion_1_1]; + + [self.wordPressComRESTAPI post:requestUrl + parameters:nil + success:^(id responseObject, NSHTTPURLResponse *httpResponse) { + if (success) { + success(); + } + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + + +#pragma mark Post-centric methods + +- (void)syncHierarchicalCommentsForPost:(NSNumber *)postID + page:(NSUInteger)page + number:(NSUInteger)number + success:(void (^)(NSArray *comments, NSNumber *found))success + failure:(void (^)(NSError *error))failure +{ + NSString *path = [NSString stringWithFormat:@"sites/%@/posts/%@/replies?order=ASC&hierarchical=1&page=%lu&number=%lu", self.siteID, postID, (unsigned long)page, (unsigned long)number]; + NSString *requestUrl = [self pathForEndpoint:path + withVersion:WordPressComRESTAPIVersion_1_1]; + + NSDictionary *parameters = @{ + @"force": @"wpcom" // Force fetching data from shadow site on Jetpack sites + }; + [self.wordPressComRESTAPI get:requestUrl + parameters:parameters + success:^(id responseObject, NSHTTPURLResponse *httpResponse) { + if (success) { + NSDictionary *dict = (NSDictionary *)responseObject; + NSArray *comments = [self remoteCommentsFromJSONArray:[dict arrayForKey:@"comments"]]; + NSNumber *found = [responseObject numberForKey:@"found"] ?: @0; + success(comments, found); + } + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + + +#pragma mark - Public Methods + +- (void)updateCommentWithID:(NSNumber *)commentID + content:(NSString *)content + success:(void (^)(RemoteComment *comment))success + failure:(void (^)(NSError *error))failure +{ + NSString *path = [NSString stringWithFormat:@"sites/%@/comments/%@", self.siteID, commentID]; + NSString *requestUrl = [self pathForEndpoint:path + withVersion:WordPressComRESTAPIVersion_1_1]; + + NSDictionary *parameters = @{ + @"content": content, + @"context": @"edit", + }; + [self.wordPressComRESTAPI post:requestUrl + parameters:parameters + success:^(id responseObject, NSHTTPURLResponse *httpResponse) { + RemoteComment *comment = [self remoteCommentFromJSONDictionary:responseObject]; + if (success) { + success(comment); + } + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + +- (void)replyToPostWithID:(NSNumber *)postID + content:(NSString *)content + success:(void (^)(RemoteComment *comment))success + failure:(void (^)(NSError *error))failure +{ + NSString *path = [NSString stringWithFormat:@"sites/%@/posts/%@/replies/new", self.siteID, postID]; + NSString *requestUrl = [self pathForEndpoint:path + withVersion:WordPressComRESTAPIVersion_1_1]; + + NSDictionary *parameters = @{@"content": content}; + + [self.wordPressComRESTAPI post:requestUrl + parameters:parameters + success:^(id responseObject, NSHTTPURLResponse *httpResponse) { + if (success) { + NSDictionary *commentDict = (NSDictionary *)responseObject; + RemoteComment *comment = [self remoteCommentFromJSONDictionary:commentDict]; + success(comment); + } + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + +- (void)replyToCommentWithID:(NSNumber *)commentID + content:(NSString *)content + success:(void (^)(RemoteComment *comment))success + failure:(void (^)(NSError *error))failure +{ + NSString *path = [NSString stringWithFormat:@"sites/%@/comments/%@/replies/new", self.siteID, commentID]; + NSString *requestUrl = [self pathForEndpoint:path + withVersion:WordPressComRESTAPIVersion_1_1]; + + NSDictionary *parameters = @{ + @"content": content, + @"context": @"edit", + }; + [self.wordPressComRESTAPI post:requestUrl + parameters:parameters + success:^(id responseObject, NSHTTPURLResponse *httpResponse) { + if (success) { + NSDictionary *commentDict = (NSDictionary *)responseObject; + RemoteComment *comment = [self remoteCommentFromJSONDictionary:commentDict]; + success(comment); + } + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + +- (void)moderateCommentWithID:(NSNumber *)commentID + status:(NSString *)status + success:(void (^)(void))success + failure:(void (^)(NSError *error))failure +{ + NSString *path = [NSString stringWithFormat:@"sites/%@/comments/%@", self.siteID, commentID]; + NSString *requestUrl = [self pathForEndpoint:path + withVersion:WordPressComRESTAPIVersion_1_1]; + + NSDictionary *parameters = @{ + @"status" : status, + @"context" : @"edit", + }; + + [self.wordPressComRESTAPI post:requestUrl + parameters:parameters + success:^(id responseObject, NSHTTPURLResponse *httpResponse) { + if (success) { + success(); + } + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + +- (void)trashCommentWithID:(NSNumber *)commentID + success:(void (^)(void))success + failure:(void (^)(NSError *error))failure +{ + NSString *path = [NSString stringWithFormat:@"sites/%@/comments/%@/delete", self.siteID, commentID]; + NSString *requestUrl = [self pathForEndpoint:path + withVersion:WordPressComRESTAPIVersion_1_1]; + + [self.wordPressComRESTAPI post:requestUrl + parameters:nil + success:^(id responseObject, NSHTTPURLResponse *httpResponse) { + if (success) { + success(); + } + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + +- (void)likeCommentWithID:(NSNumber *)commentID + success:(void (^)(void))success + failure:(void (^)(NSError *error))failure +{ + NSString *path = [NSString stringWithFormat:@"sites/%@/comments/%@/likes/new", self.siteID, commentID]; + NSString *requestUrl = [self pathForEndpoint:path + withVersion:WordPressComRESTAPIVersion_1_1]; + + [self.wordPressComRESTAPI post:requestUrl + parameters:nil + success:^(id responseObject, NSHTTPURLResponse *httpResponse) { + if (success) { + success(); + } + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + +- (void)unlikeCommentWithID:(NSNumber *)commentID + success:(void (^)(void))success + failure:(void (^)(NSError *error))failure +{ + NSString *path = [NSString stringWithFormat:@"sites/%@/comments/%@/likes/mine/delete", self.siteID, commentID]; + NSString *requestUrl = [self pathForEndpoint:path + withVersion:WordPressComRESTAPIVersion_1_1]; + + [self.wordPressComRESTAPI post:requestUrl + parameters:nil + success:^(id responseObject, NSHTTPURLResponse *httpResponse) { + if (success) { + success(); + } + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + +- (void)getLikesForCommentID:(NSNumber *)commentID + count:(NSNumber *)count + before:(NSString *)before + excludeUserIDs:(NSArray *)excludeUserIDs + success:(void (^)(NSArray * _Nonnull users, NSNumber *found))success + failure:(void (^)(NSError *))failure +{ + NSParameterAssert(commentID); + + NSString *path = [NSString stringWithFormat:@"sites/%@/comments/%@/likes", self.siteID, commentID]; + NSString *requestUrl = [self pathForEndpoint:path + withVersion:WordPressComRESTAPIVersion_1_2]; + NSNumber *siteID = self.siteID; + + // If no count provided, default to endpoint max. + if (count == 0) { + count = @90; + } + + NSMutableDictionary *parameters = [NSMutableDictionary dictionaryWithDictionary:@{ @"number": count }]; + + if (before) { + parameters[@"before"] = before; + } + + if (excludeUserIDs) { + parameters[@"exclude"] = excludeUserIDs; + } + + [self.wordPressComRESTAPI get:requestUrl + parameters:parameters + success:^(id responseObject, NSHTTPURLResponse *httpResponse) { + if (success) { + NSArray *jsonUsers = responseObject[@"likes"] ?: @[]; + NSArray *users = [self remoteUsersFromJSONArray:jsonUsers commentID:commentID siteID:siteID]; + NSNumber *found = [responseObject numberForKey:@"found"] ?: @0; + success(users, found); + } + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + +#pragma mark - Private methods + +- (NSArray *)remoteCommentsFromJSONArray:(NSArray *)jsonComments +{ + return [jsonComments wpkit_map:^id(NSDictionary *jsonComment) { + return [self remoteCommentFromJSONDictionary:jsonComment]; + }]; +} + +- (RemoteComment *)remoteCommentFromJSONDictionary:(NSDictionary *)jsonDictionary +{ + RemoteComment *comment = [RemoteComment new]; + + comment.authorID = [jsonDictionary numberForKeyPath:@"author.ID"]; + comment.author = jsonDictionary[@"author"][@"name"]; + // Email might be `false`, turn into `nil` + comment.authorEmail = [jsonDictionary[@"author"] stringForKey:@"email"]; + comment.authorUrl = jsonDictionary[@"author"][@"URL"]; + comment.authorAvatarURL = [jsonDictionary stringForKeyPath:@"author.avatar_URL"]; + comment.authorIP = [jsonDictionary stringForKeyPath:@"author.ip_address"]; + comment.commentID = jsonDictionary[@"ID"]; + comment.date = [NSDate dateWithWordPressComJSONString:jsonDictionary[@"date"]]; + comment.link = jsonDictionary[@"URL"]; + comment.parentID = [jsonDictionary numberForKeyPath:@"parent.ID"]; + comment.postID = [jsonDictionary numberForKeyPath:@"post.ID"]; + comment.postTitle = [jsonDictionary stringForKeyPath:@"post.title"]; + comment.status = [self statusWithRemoteStatus:jsonDictionary[@"status"]]; + comment.type = jsonDictionary[@"type"]; + comment.isLiked = [[jsonDictionary numberForKey:@"i_like"] boolValue]; + comment.likeCount = [jsonDictionary numberForKey:@"like_count"]; + comment.canModerate = [[jsonDictionary numberForKey:@"can_moderate"] boolValue]; + comment.content = jsonDictionary[@"content"]; + comment.rawContent = jsonDictionary[@"raw_content"]; + + return comment; +} + +- (NSString *)statusWithRemoteStatus:(NSString *)remoteStatus +{ + NSString *status = remoteStatus; + if ([status isEqualToString:@"unapproved"]) { + status = @"hold"; + } else if ([status isEqualToString:@"approved"]) { + status = @"approve"; + } + return status; +} + +- (NSString *)remoteStatusWithStatus:(NSString *)status +{ + NSString *remoteStatus = status; + if ([remoteStatus isEqualToString:@"hold"]) { + remoteStatus = @"unapproved"; + } else if ([remoteStatus isEqualToString:@"approve"]) { + remoteStatus = @"approved"; + } + return remoteStatus; +} + +/** + Returns an array of RemoteLikeUser based on provided JSON representation of users. + + @param jsonUsers An array containing JSON representations of users. + @param commentID ID of the Comment the users liked. + @param siteID ID of the Comment's site. + */ +- (NSArray *)remoteUsersFromJSONArray:(NSArray *)jsonUsers + commentID:(NSNumber *)commentID + siteID:(NSNumber *)siteID +{ + return [jsonUsers wpkit_map:^id(NSDictionary *jsonUser) { + return [[RemoteLikeUser alloc] initWithDictionary:jsonUser commentID:commentID siteID:siteID]; + }]; +} + +@end diff --git a/Modules/Sources/WordPressKitObjC/CommentServiceRemoteXMLRPC.m b/Modules/Sources/WordPressKitObjC/CommentServiceRemoteXMLRPC.m new file mode 100644 index 000000000000..5a7fd9091132 --- /dev/null +++ b/Modules/Sources/WordPressKitObjC/CommentServiceRemoteXMLRPC.m @@ -0,0 +1,231 @@ +#import "CommentServiceRemoteXMLRPC.h" +#import "RemoteComment.h" +#import "WPMapFilterReduce.h" + +@import wpxmlrpc; +@import NSObject_SafeExpectations; + +@implementation CommentServiceRemoteXMLRPC + +- (void)getCommentsWithMaximumCount:(NSInteger)maximumComments + success:(void (^)(NSArray *comments))success + failure:(void (^)(NSError *error))failure +{ + [self getCommentsWithMaximumCount:maximumComments options:nil success:success failure:failure]; +} + +- (void)getCommentsWithMaximumCount:(NSInteger)maximumComments + options:(NSDictionary *)options + success:(void (^)(NSArray *posts))success + failure:(void (^)(NSError *error))failure +{ + NSMutableDictionary *extraParameters = [@{ @"number": @(maximumComments) } mutableCopy]; + + if (options) { + [extraParameters addEntriesFromDictionary:options]; + } + + NSNumber *statusFilter = [extraParameters numberForKey:@"status"]; + [extraParameters removeObjectForKey:@"status"]; + extraParameters[@"status"] = [self parameterForCommentStatus:statusFilter]; + + NSArray *parameters = [self XMLRPCArgumentsWithExtra:extraParameters]; + + [self.api callMethod:@"wp.getComments" + parameters:parameters + success:^(id responseObject, NSHTTPURLResponse *httpResponse) { + NSAssert([responseObject isKindOfClass:[NSArray class]], @"Response should be an array."); + if (success) { + success([self remoteCommentsFromXMLRPCArray:responseObject]); + } + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + +- (NSString *)parameterForCommentStatus:(NSNumber *)status +{ + switch (status.intValue) { + case CommentStatusFilterUnapproved: + return @"hold"; + break; + case CommentStatusFilterApproved: + return @"approve"; + break; + case CommentStatusFilterTrash: + return @"trash"; + break; + case CommentStatusFilterSpam: + return @"spam"; + break; + default: + return @"all"; + break; + } +} + +- (void)getCommentWithID:(NSNumber *)commentID + success:(void (^)(RemoteComment *comment))success + failure:(void (^)(NSError *))failure +{ + NSArray *parameters = [self XMLRPCArgumentsWithExtra:commentID]; + [self.api callMethod:@"wp.getComment" + parameters:parameters success:^(id responseObject, NSHTTPURLResponse *httpResponse) { + if (success) { + // TODO: validate response + RemoteComment *comment = [self remoteCommentFromXMLRPCDictionary:responseObject]; + success(comment); + } + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + failure(error); + }]; +} + +- (void)createComment:(RemoteComment *)comment + success:(void (^)(RemoteComment *comment))success + failure:(void (^)(NSError *error))failure +{ + NSParameterAssert(comment.postID != nil); + NSDictionary *commentDictionary = @{ + @"content": comment.content, + @"comment_parent": comment.parentID, + }; + NSArray *extraParameters = @[ + comment.postID, + commentDictionary, + ]; + NSArray *parameters = [self XMLRPCArgumentsWithExtra:extraParameters]; + [self.api callMethod:@"wp.newComment" + parameters:parameters + success:^(id responseObject, NSHTTPURLResponse *httpResponse) { + NSNumber *commentID = responseObject; + // TODO: validate response + [self getCommentWithID:commentID + success:success + failure:failure]; + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + +- (void)updateComment:(RemoteComment *)comment + success:(void (^)(RemoteComment *comment))success + failure:(void (^)(NSError *error))failure +{ + NSParameterAssert(comment.commentID != nil); + NSNumber *commentID = comment.commentID; + + NSDictionary *commentDictionary = @{ + @"content": comment.content, + @"author": comment.author, + @"author_email": comment.authorEmail, + @"author_url": comment.authorUrl, + }; + + NSArray *extraParameters = @[ + comment.commentID, + commentDictionary, + ]; + + NSArray *parameters = [self XMLRPCArgumentsWithExtra:extraParameters]; + + [self.api callMethod:@"wp.editComment" + parameters:parameters + success:^(id responseObject, NSHTTPURLResponse *httpResponse) { + // TODO: validate response + [self getCommentWithID:commentID + success:success + failure:failure]; + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + +- (void)moderateComment:(RemoteComment *)comment + success:(void (^)(RemoteComment *))success + failure:(void (^)(NSError *))failure +{ + NSParameterAssert(comment.commentID != nil); + NSNumber *commentID = comment.commentID; + NSArray *extraParameters = @[ + commentID, + @{@"status": comment.status}, + ]; + NSArray *parameters = [self XMLRPCArgumentsWithExtra:extraParameters]; + [self.api callMethod:@"wp.editComment" + parameters:parameters + success:^(id responseObject, NSHTTPURLResponse *httpResponse) { + // TODO: validate response + [self getCommentWithID:commentID + success:success + failure:failure]; + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + // If the error is a 500 this could be a signal that the error changed status on the server + if ([error.domain isEqualToString:WPXMLRPCFaultErrorDomain] + && error.code == 500) { + if (success) { + success(comment); + } + return; + } + if (failure) { + failure(error); + } + }]; +} + +- (void)trashComment:(RemoteComment *)comment + success:(void (^)(void))success + failure:(void (^)(NSError *))failure +{ + NSParameterAssert(comment.commentID != nil); + NSArray *parameters = [self XMLRPCArgumentsWithExtra:comment.commentID]; + [self.api callMethod:@"wp.deleteComment" + parameters:parameters + success:^(id responseObject, NSHTTPURLResponse *httpResponse) { + if (success) { + success(); + } + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + +#pragma mark - Private methods + +- (NSArray *)remoteCommentsFromXMLRPCArray:(NSArray *)xmlrpcArray +{ + return [xmlrpcArray wpkit_map:^id(NSDictionary *xmlrpcComment) { + return [self remoteCommentFromXMLRPCDictionary:xmlrpcComment]; + }]; +} + +- (RemoteComment *)remoteCommentFromXMLRPCDictionary:(NSDictionary *)xmlrpcDictionary +{ + RemoteComment *comment = [RemoteComment new]; + comment.author = xmlrpcDictionary[@"author"]; + comment.authorEmail = xmlrpcDictionary[@"author_email"]; + comment.authorUrl = xmlrpcDictionary[@"author_url"]; + comment.authorIP = xmlrpcDictionary[@"author_ip"]; + comment.commentID = [xmlrpcDictionary numberForKey:@"comment_id"]; + comment.content = xmlrpcDictionary[@"content"]; + comment.date = xmlrpcDictionary[@"date_created_gmt"]; + comment.link = xmlrpcDictionary[@"link"]; + comment.parentID = [xmlrpcDictionary numberForKey:@"parent"]; + comment.postID = [xmlrpcDictionary numberForKey:@"post_id"]; + comment.postTitle = xmlrpcDictionary[@"post_title"]; + comment.status = xmlrpcDictionary[@"status"]; + comment.type = xmlrpcDictionary[@"type"]; + + return comment; +} + +@end diff --git a/Modules/Sources/WordPressKitObjC/DisplayableImageHelper.m b/Modules/Sources/WordPressKitObjC/DisplayableImageHelper.m new file mode 100644 index 000000000000..d8d360c177f8 --- /dev/null +++ b/Modules/Sources/WordPressKitObjC/DisplayableImageHelper.m @@ -0,0 +1,281 @@ +#import "DisplayableImageHelper.h" +#import "NSString+Helpers.h" + +static const NSInteger FeaturedImageMinimumWidth = 150; + +static NSString * const AttachmentsDictionaryKeyWidth = @"width"; +static NSString * const AttachmentsDictionaryKeyURL = @"URL"; +static NSString * const AttachmentsDictionaryKeyMimeType = @"mime_type"; + +@implementation WPKitDisplayableImageHelper + ++ (NSInteger)widthOfAttachment:(NSDictionary *)attachment { + NSInteger result = 0; + id obj = [attachment objectForKey:AttachmentsDictionaryKeyWidth]; + if ([obj isKindOfClass:NSNumber.class]) { + NSNumber *number = (NSNumber *)obj; + result = [number integerValue]; + } else if ([obj isKindOfClass:NSString.class]) { + NSNumberFormatter *numberFormatter = [[NSNumberFormatter alloc] init]; + numberFormatter.locale = [NSLocale localeWithLocaleIdentifier:@"en_US_POSIX"]; + NSNumber *number= [numberFormatter numberFromString:(NSString *)obj]; + result = [number integerValue]; + } + return result; +} + ++ (NSString *)searchPostAttachmentsForImageToDisplay:(NSDictionary *)attachmentsDict existingInContent:(NSString *)content +{ + NSArray *attachments = [attachmentsDict allValues]; + if ([attachments count] == 0) { + return nil; + } + + NSString *imageToDisplay; + + attachments = [self filteredAttachmentsArray:attachments]; + + for (NSDictionary *attachment in attachments) { + NSInteger width = [self widthOfAttachment:attachment]; + if (width < FeaturedImageMinimumWidth) { + // The remaining images are too small so just stop now. + break; + } + id obj = attachment[AttachmentsDictionaryKeyURL]; + if ([obj isKindOfClass:NSString.class]) { + NSString *maybeImage = (NSString *)obj; + if ([content containsString:maybeImage]) { + imageToDisplay = maybeImage; + break; + } + } + } + + return imageToDisplay; +} + ++ (NSArray *)filteredAttachmentsArray:(NSArray *)attachments +{ + NSString *key = AttachmentsDictionaryKeyMimeType; + NSPredicate *predicate = [NSPredicate predicateWithFormat:@"%K BEGINSWITH %@", key, @"image"]; + attachments = [attachments filteredArrayUsingPredicate:predicate]; + attachments = [self sortAttachmentsArray:attachments]; + return attachments; +} + ++ (NSArray *)sortAttachmentsArray:(NSArray *)attachments +{ + return [attachments sortedArrayUsingComparator:^NSComparisonResult(NSDictionary *attachmentA, NSDictionary *attachmentB) { + NSInteger widthA = [self widthOfAttachment:attachmentA]; + NSInteger widthB = [self widthOfAttachment:attachmentB]; + + if (widthA < widthB) { + return NSOrderedDescending; + } else if (widthA > widthB) { + return NSOrderedAscending; + } else { + return NSOrderedSame; + } + }]; +} + ++ (NSString *)searchPostContentForImageToDisplay:(NSString *)content +{ + NSString *imageSrc = @""; + // If there is no image tag in the content, just bail. + if (!content || [content rangeOfString:@""; + regex = [NSRegularExpression regularExpressionWithPattern:imgPattern options:NSRegularExpressionCaseInsensitive error:&error]; + }); + + // Find all the image tags in the content passed. + NSArray *matches = [regex matchesInString:content options:0 range:NSMakeRange(0, [content length])]; + + for (NSTextCheckingResult *match in matches) { + NSString *tag = [content substringWithRange:match.range]; + NSString *src = [self extractSrcFromImgTag:tag]; + + // Ignore WordPress emoji images + if ([src rangeOfString:@"/images/core/emoji/"].location != NSNotFound || + [src rangeOfString:@"/wp-includes/images/smilies/"].location != NSNotFound || + [src rangeOfString:@"/wp-content/mu-plugins/wpcom-smileys/"].location != NSNotFound) { + continue; + } + + // Ignore .svg images since we can't display them in a UIImageView + if ([src rangeOfString:@".svg"].location != NSNotFound) { + continue; + } + + // Check the tag for a good width + NSInteger width = MAX([self widthFromElementAttribute:tag], [self widthFromQueryString:src]); + if (width > FeaturedImageMinimumWidth) { + imageSrc = src; + break; + } + } + if (imageSrc.length == 0) { + imageSrc = [self searchContentBySizeClassForImageToFeature:content]; + } + + return imageSrc; +} + ++ (NSSet *)searchPostContentForAttachmentIdsInGalleries:(NSString *)content +{ + NSMutableSet *resultSet = [NSMutableSet set]; + // If there is no gallery shortcode in the content, just bail. + if (!content || [content rangeOfString:@"[gallery "].location == NSNotFound) { + return resultSet; + } + + // Get all the things + static NSRegularExpression *regexGallery; + static dispatch_once_t onceTokenRegexGallery; + dispatch_once(&onceTokenRegexGallery, ^{ + NSError *error; + NSString *galleryPattern = @"\\[gallery[^]]+ids=\"([0-9,]*)\"[^]]*\\]"; + regexGallery = [NSRegularExpression regularExpressionWithPattern:galleryPattern options:NSRegularExpressionCaseInsensitive error:&error]; + }); + + // Find all the gallery shortcodes in the content passed. + NSArray *matches = [regexGallery matchesInString:content options:0 range:NSMakeRange(0, [content length])]; + + for (NSTextCheckingResult *match in matches) { + if (match.numberOfRanges < 2) { + continue; + } + NSString *tag = [content substringWithRange:[match rangeAtIndex:1]]; + NSSet *tagIds = [self idsFromGallery:tag]; + [resultSet unionSet:tagIds]; + } + return resultSet; +} + +/** + Extract the path to an image from an image tag. + + @param tag An image tag. + @return The value of the src param. + */ ++ (NSString *)extractSrcFromImgTag:(NSString *)tag +{ + static NSRegularExpression *regex; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + NSError *error; + NSString *srcPattern = @"src\\s*=\\s*(?:'|\")(.*?)(?:'|\")"; + regex = [NSRegularExpression regularExpressionWithPattern:srcPattern options:NSRegularExpressionCaseInsensitive error:&error]; + }); + + NSRange srcRng = [regex rangeOfFirstMatchInString:tag options:0 range:NSMakeRange(0, [tag length])]; + NSString *src = [tag substringWithRange:srcRng]; + NSCharacterSet *charSet = [NSCharacterSet characterSetWithCharactersInString:@"\"'="]; + NSRange quoteRng = [src rangeOfCharacterFromSet:charSet]; + src = [src substringFromIndex:quoteRng.location]; + src = [src stringByTrimmingCharactersInSet:charSet]; + return src; +} + +/** + Search the passed string for an image that is a good candidate to feature. + @param content The content string to search. + @return The url path for the image or an empty string. + */ ++ (NSString *)searchContentBySizeClassForImageToFeature:(NSString *)content +{ + NSString *str = @""; + // If there is no image tag in the content, just bail. + if (!content || [content rangeOfString:@" 0) { + for (NSDictionary *returnedMediaDict in mediaList) { + RemoteMedia *remoteMedia = [MediaServiceRemoteREST remoteMediaFromJSONDictionary:returnedMediaDict]; + [returnedRemoteMedia addObject:remoteMedia]; + } + + if (success) { + success(returnedRemoteMedia); + } + } else { + NSError *error = [self processMediaUploadErrors:errorList]; + if (failure) { + failure(error); + } + } + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + WPKitLogDebug(@"Error uploading multiple media files: %@", [error localizedDescription]); + if (failure) { + failure(error); + } + }]; + +} + +- (void)uploadMedia:(RemoteMedia *)media + progress:(NSProgress **)progress + success:(void (^)(RemoteMedia *remoteMedia))success + failure:(void (^)(NSError *error))failure +{ + NSString *type = media.mimeType; + NSString *filename = media.file; + + NSString *apiPath = [NSString stringWithFormat:@"sites/%@/media/new", self.siteID]; + NSString *requestUrl = [self pathForEndpoint:apiPath + withVersion:WordPressComRESTAPIVersion_1_1]; + + NSDictionary *parameters = [self parametersForUploadMedia:media]; + + if (media.localURL == nil || filename == nil || type == nil) { + if (failure) { + NSError *error = [NSError errorWithDomain:NSURLErrorDomain + code:NSURLErrorFileDoesNotExist + userInfo:@{NSLocalizedDescriptionKey: NSLocalizedString(@"Media doesn't have an associated file to upload.", @"Error message to show to users when trying to upload a media object with no local file associated")}]; + failure(error); + } + return; + } + FilePart *filePart = [[FilePart alloc] initWithParameterName:@"media[]" url:media.localURL fileName:filename mimeType:type]; + __block NSProgress *localProgress = [self.wordPressComRESTAPI multipartPOST:requestUrl + parameters:parameters + fileParts:@[filePart] + requestEnqueued:nil + success:^(id _Nonnull responseObject, NSHTTPURLResponse * _Nullable httpResponse) { + NSDictionary *response = (NSDictionary *)responseObject; + NSArray *errorList = response[@"errors"]; + NSArray *mediaList = response[@"media"]; + if (mediaList.count > 0){ + RemoteMedia *remoteMedia = [MediaServiceRemoteREST remoteMediaFromJSONDictionary:mediaList[0]]; + if (success) { + success(remoteMedia); + } + } else { + NSError *error = [self processMediaUploadErrors:errorList]; + if (failure) { + failure(error); + } + } + + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + WPKitLogDebug(@"Error uploading file: %@", [error localizedDescription]); + if (failure) { + failure(error); + } + }]; + + *progress = localProgress; +} + +- (NSError *)processMediaUploadErrors:(NSArray *)errorList { + WPKitLogDebug(@"Error uploading file: %@", errorList); + NSError * error = nil; + if (errorList.count > 0) { + NSString *errorMessage = [errorList.firstObject description]; + if ([errorList.firstObject isKindOfClass:NSDictionary.class]) { + NSDictionary *errorInfo = errorList.firstObject; + errorMessage = errorInfo[@"message"]; + } + NSDictionary *errorDictionary = @{NSLocalizedDescriptionKey: errorMessage}; + error = [[NSError alloc] initWithDomain:WordPressComRestApiErrorDomain + code:self.wordPressComRESTAPI.uploadFailedErrorCode + userInfo:errorDictionary]; + } + return error; +} + +- (void)updateMedia:(RemoteMedia *)media + success:(void (^)(RemoteMedia *remoteMedia))success + failure:(void (^)(NSError *error))failure +{ + NSParameterAssert([media isKindOfClass:[RemoteMedia class]]); + + NSString *path = [NSString stringWithFormat:@"sites/%@/media/%@", self.siteID, media.mediaID]; + NSString *requestUrl = [self pathForEndpoint:path + withVersion:WordPressComRESTAPIVersion_1_1]; + + NSDictionary *parameters = [self parametersFromRemoteMedia:media]; + + [self.wordPressComRESTAPI post:requestUrl + parameters:parameters + success:^(id responseObject, NSHTTPURLResponse *response) { + RemoteMedia *media = [MediaServiceRemoteREST remoteMediaFromJSONDictionary:responseObject]; + if (success) { + success(media); + } + } failure:^(NSError *error, NSHTTPURLResponse *response) { + if (failure) { + failure(error); + } + }]; +} + +- (void)deleteMedia:(RemoteMedia *)media + success:(void (^)(void))success + failure:(void (^)(NSError *))failure +{ + NSParameterAssert([media isKindOfClass:[RemoteMedia class]]); + + NSString *path = [NSString stringWithFormat:@"sites/%@/media/%@/delete", self.siteID, media.mediaID]; + NSString *requestUrl = [self pathForEndpoint:path + withVersion:WordPressComRESTAPIVersion_1_1]; + + [self.wordPressComRESTAPI post:requestUrl + parameters:nil + success:^(id responseObject, NSHTTPURLResponse *httpResponse) { + NSDictionary *response = (NSDictionary *)responseObject; + NSString *status = [response stringForKey:@"status"]; + if ([status isEqualToString:@"deleted"]) { + if (success) { + success(); + } + } else { + if (failure) { + failure(self.wordPressComRESTAPI.unknownResponseError); + } + } + } failure:^(NSError *error, NSHTTPURLResponse *response) { + if (failure) { + failure(error); + } + }]; +} + +-(void)getMetadataFromVideoPressID:(NSString *)videoPressID + isSitePrivate:(BOOL)isSitePrivate + success:(void (^)(RemoteVideoPressVideo *metadata))success + failure:(void (^)(NSError *))failure +{ + NSString *path = [NSString stringWithFormat:@"videos/%@", videoPressID]; + NSString *requestUrl = [self pathForEndpoint:path + withVersion:WordPressComRESTAPIVersion_1_1]; + + [self.wordPressComRESTAPI get:requestUrl + parameters:nil + success:^(id responseObject, NSHTTPURLResponse *httpResponse) { + NSDictionary *response = (NSDictionary *)responseObject; + RemoteVideoPressVideo *video = [[RemoteVideoPressVideo alloc] initWithDictionary:response id:videoPressID]; + + BOOL needsToken = video.privacySetting == VideoPressPrivacySettingIsPrivate || (video.privacySetting == VideoPressPrivacySettingSiteDefault && isSitePrivate); + if(needsToken) { + [self getVideoPressToken:videoPressID success:^(NSString *token) { + video.token = token; + if (success) { + success(video); + } + } failure:^(NSError * error) { + if (failure) { + failure(error); + } + }]; + } + else { + if (success) { + success(video); + } + } + } failure:^(NSError *error, NSHTTPURLResponse *response) { + if (failure) { + failure(error); + } + }]; +} + +-(void)getVideoPressToken:(NSString *)videoPressID + success:(void (^)(NSString *token))success + failure:(void (^)(NSError *))failure +{ + + NSString *path = [NSString stringWithFormat:@"sites/%@/media/videopress-playback-jwt/%@", self.siteID, videoPressID]; + NSString *requestUrl = [self pathForEndpoint:path + withVersion:WordPressComRESTAPIVersion_2_0]; + + [self.wordPressComRESTAPI post:requestUrl + parameters:nil + success:^(id responseObject, NSHTTPURLResponse *httpResponse) { + NSDictionary *response = (NSDictionary *)responseObject; + NSString *token = [response stringForKey:@"metadata_token"]; + if (token) { + if (success) { + success(token); + } + } else { + if (failure) { + failure(self.wordPressComRESTAPI.unknownResponseError); + } + } + } failure:^(NSError *error, NSHTTPURLResponse *response) { + if (failure) { + failure(error); + } + }]; +} + ++ (NSArray *)remoteMediaFromJSONArray:(NSArray *)jsonMedia +{ + return [jsonMedia wpkit_map:^id(NSDictionary *json) { + return [self remoteMediaFromJSONDictionary:json]; + }]; +} + ++ (RemoteMedia *)remoteMediaFromJSONDictionary:(NSDictionary *)jsonMedia +{ + RemoteMedia * remoteMedia=[[RemoteMedia alloc] init]; + remoteMedia.mediaID = [jsonMedia numberForKey:@"ID"]; + remoteMedia.url = [NSURL URLWithString:[jsonMedia stringForKey:@"URL"]]; + remoteMedia.guid = [NSURL URLWithString:[jsonMedia stringForKey:@"guid"]]; + remoteMedia.date = [NSDate dateWithWordPressComJSONString:jsonMedia[@"date"]]; + remoteMedia.postID = [jsonMedia numberForKey:@"post_ID"]; + remoteMedia.file = [jsonMedia stringForKey:@"file"]; + remoteMedia.largeURL = [NSURL URLWithString:[jsonMedia valueForKeyPath :@"thumbnails.large"]]; + remoteMedia.mediumURL = [NSURL URLWithString:[jsonMedia valueForKeyPath :@"thumbnails.medium"]]; + remoteMedia.mimeType = [jsonMedia stringForKey:@"mime_type"]; + remoteMedia.extension = [jsonMedia stringForKey:@"extension"]; + remoteMedia.title = [jsonMedia stringForKey:@"title"]; + remoteMedia.caption = [jsonMedia stringForKey:@"caption"]; + remoteMedia.descriptionText = [jsonMedia stringForKey:@"description"]; + remoteMedia.alt = [jsonMedia stringForKey:@"alt"]; + remoteMedia.height = [jsonMedia numberForKey:@"height"]; + remoteMedia.width = [jsonMedia numberForKey:@"width"]; + remoteMedia.exif = [jsonMedia dictionaryForKey:@"exif"]; + remoteMedia.remoteThumbnailURL = [jsonMedia stringForKeyPath:@"thumbnails.fmt_std"]; + remoteMedia.videopressGUID = [jsonMedia stringForKey:@"videopress_guid"]; + remoteMedia.length = [jsonMedia numberForKey:@"length"]; + return remoteMedia; +} + +- (NSDictionary *)parametersFromRemoteMedia:(RemoteMedia *)remoteMedia +{ + NSMutableDictionary *parameters = [NSMutableDictionary dictionary]; + + if (remoteMedia.postID != nil) { + parameters[@"parent_id"] = remoteMedia.postID; + } + if (remoteMedia.title != nil) { + parameters[@"title"] = remoteMedia.title; + } + + if (remoteMedia.caption != nil) { + parameters[@"caption"] = remoteMedia.caption; + } + + if (remoteMedia.descriptionText != nil) { + parameters[@"description"] = remoteMedia.descriptionText; + } + + if (remoteMedia.alt != nil) { + parameters[@"alt"] = remoteMedia.alt; + } + + return [NSDictionary dictionaryWithDictionary:parameters]; +} + +- (NSDictionary *)parametersForUploadMedia:(RemoteMedia *)media +{ + NSMutableDictionary *parameters = [NSMutableDictionary dictionary]; + + if (media.caption != nil) { + parameters[@"attrs[0][caption]"] = media.caption; + } + if (media.postID != nil && [media.postID compare:@(0)] == NSOrderedDescending) { + parameters[@"attrs[0][parent_id]"] = media.postID; + } + + return [NSDictionary dictionaryWithDictionary:parameters]; +} + +@end diff --git a/Modules/Sources/WordPressKitObjC/MediaServiceRemoteXMLRPC.m b/Modules/Sources/WordPressKitObjC/MediaServiceRemoteXMLRPC.m new file mode 100644 index 000000000000..19a5bb3ae15e --- /dev/null +++ b/Modules/Sources/WordPressKitObjC/MediaServiceRemoteXMLRPC.m @@ -0,0 +1,350 @@ +#import "MediaServiceRemoteXMLRPC.h" +#import "RemoteMedia.h" +#import "WPMapFilterReduce.h" + +@import NSObject_SafeExpectations; + +@implementation MediaServiceRemoteXMLRPC + +- (void)getMediaWithID:(NSNumber *)mediaID + success:(void (^)(RemoteMedia *remoteMedia))success + failure:(void (^)(NSError *error))failure +{ + NSArray *parameters = [self XMLRPCArgumentsWithExtra:mediaID]; + [self.api callMethod:@"wp.getMediaItem" + parameters:parameters + success:^(id responseObject, NSHTTPURLResponse *httpResponse) { + if (success) { + NSDictionary * xmlRPCDictionary = (NSDictionary *)responseObject; + RemoteMedia * remoteMedia = [self remoteMediaFromXMLRPCDictionary:xmlRPCDictionary]; + success(remoteMedia); + } + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + +- (void)getMediaLibraryWithPageLoad:(void (^)(NSArray *))pageLoad + success:(void (^)(NSArray *))success + failure:(void (^)(NSError *))failure +{ + [self getMediaLibraryStartOffset:0 media:@[] pageLoad:pageLoad success:success failure:failure]; +} + +- (void)getMediaLibraryStartOffset:(NSUInteger)offset + media:(NSArray *)media + pageLoad:(void (^)(NSArray *))pageLoad + success:(void (^)(NSArray *))success + failure:(void (^)(NSError *))failure +{ + NSInteger pageSize = 100; + NSDictionary *filter = @{ + @"number": @(pageSize), + @"offset": @(offset) + }; + NSArray *parameters = [self XMLRPCArgumentsWithExtra:filter]; + + [self.api callMethod:@"wp.getMediaLibrary" + parameters:parameters + success:^(id responseObject, NSHTTPURLResponse *httpResponse) { + NSAssert([responseObject isKindOfClass:[NSArray class]], @"Response should be an array."); + if (!success) { + return; + } + NSArray *pageMedia = [self remoteMediaFromXMLRPCArray:responseObject]; + NSArray *resultMedia = [media arrayByAddingObjectsFromArray:pageMedia]; + // Did we got all the items we requested or it's finished? + if (pageMedia.count < pageSize) { + success(resultMedia); + return; + } + if(pageLoad) { + pageLoad(pageMedia); + } + NSUInteger newOffset = offset + pageSize; + [self getMediaLibraryStartOffset:newOffset media:resultMedia pageLoad:pageLoad success: success failure: failure]; + } + failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + +- (void)getMediaLibraryCountForType:(NSString *)mediaType + withSuccess:(void (^)(NSInteger))success + failure:(void (^)(NSError *))failure +{ + NSDictionary *data = @{}; + if (mediaType) { + data = @{@"filter":@{ @"mime_type": mediaType }}; + } + NSArray *parameters = [self XMLRPCArgumentsWithExtra:data]; + [self.api callMethod:@"wp.getMediaLibrary" + parameters:parameters + success:^(id responseObject, NSHTTPURLResponse *httpResponse) { + NSAssert([responseObject isKindOfClass:[NSArray class]], @"Response should be an array."); + if (success) { + success([responseObject count]); + } + } + failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + + +- (NSURLCredential *)findCredentialForHost:(NSString *)host port:(NSInteger)port +{ + __block NSURLCredential *foundCredential = nil; + [[[NSURLCredentialStorage sharedCredentialStorage] allCredentials] enumerateKeysAndObjectsUsingBlock:^(NSURLProtectionSpace *ps, NSDictionary *dict, BOOL *stop) { + [dict enumerateKeysAndObjectsUsingBlock:^(id key, NSURLCredential *credential, BOOL *stop) { + if ([[ps host] isEqualToString:host] && [ps port] == port) + + { + foundCredential = credential; + *stop = YES; + } + }]; + if (foundCredential) { + *stop = YES; + } + }]; + return foundCredential; +} + +/** + Adds a basic auth header to a request if a credential is stored for that specific host. + + The credentials will only be added if a set of credentials for the request host are stored on the shared credential storage + @param request The request to where the authentication information will be added. + */ +- (void)addBasicAuthCredentialsIfAvailableToRequest:(NSMutableURLRequest *)request +{ + NSInteger port = [[request.URL port] integerValue]; + if (port == 0) { + port = 80; + } + + NSURLCredential *credential = [self findCredentialForHost:request.URL.host port:port]; + if (credential) { + NSString *authStr = [NSString stringWithFormat:@"%@:%@", [credential user], [credential password]]; + NSData *authData = [authStr dataUsingEncoding:NSUTF8StringEncoding]; + NSString *authValue = [NSString stringWithFormat:@"Basic %@", [authData base64EncodedStringWithOptions:NSDataBase64Encoding64CharacterLineLength]]; + [request setValue:authValue forHTTPHeaderField:@"Authorization"]; + } +} + +- (void)uploadMedia:(RemoteMedia *)media + progress:(NSProgress **)progress + success:(void (^)(RemoteMedia *remoteMedia))success + failure:(void (^)(NSError *error))failure +{ + NSString *type = media.mimeType; + NSString *filename = media.file; + if (media.localURL == nil || filename == nil || type == nil) { + if (failure) { + NSError *error = [NSError errorWithDomain:NSURLErrorDomain + code:NSURLErrorFileDoesNotExist + userInfo:@{NSLocalizedDescriptionKey: NSLocalizedString(@"Media doesn't have an associated file to upload.", @"Error message to show to users when trying to upload a media object with no local file associated")}]; + failure(error); + } + return; + } + NSMutableDictionary *data = [NSMutableDictionary dictionaryWithDictionary:@{ + @"name": filename, + @"type": type, + @"bits": [NSInputStream inputStreamWithFileAtPath:media.localURL.path], + }]; + if ([media.postID compare:@(0)] == NSOrderedDescending) { + data[@"post_id"] = media.postID; + } + + NSArray *parameters = [self XMLRPCArgumentsWithExtra:data]; + + __block NSProgress *localProgress = [self.api streamCallMethod:@"wp.uploadFile" + parameters:parameters + success:^(id responseObject, NSHTTPURLResponse *httpResponse) { + NSDictionary *response = (NSDictionary *)responseObject; + if (![response isKindOfClass:[NSDictionary class]]) { + NSError *error = [NSError errorWithDomain:NSURLErrorDomain code:NSURLErrorBadServerResponse userInfo:@{NSLocalizedDescriptionKey: NSLocalizedString(@"The server returned an empty response. This usually means you need to increase the memory limit for your site.", @"")}]; + if (failure) { + failure(error); + } + } else { + localProgress.completedUnitCount=localProgress.totalUnitCount; + RemoteMedia * remoteMedia = [self remoteMediaFromXMLRPCDictionary:response]; + if (success){ + success(remoteMedia); + } + } + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; + + + if (progress) { + *progress = localProgress; + } +} + +- (void)updateMedia:(RemoteMedia *)media + success:(void (^)(RemoteMedia *remoteMedia))success + failure:(void (^)(NSError *error))failure +{ + NSParameterAssert([media.mediaID longLongValue] > 0); + + NSMutableDictionary *content = [NSMutableDictionary dictionary]; + + if (media.title != nil) { + content[@"post_title"] = media.title; + } + + if (media.caption != nil) { + content[@"post_excerpt"] = media.caption; + } + + if (media.descriptionText != nil) { + content[@"post_content"] = media.descriptionText; + } + + NSArray *extraDefaults = @[media.mediaID]; + NSArray *parameters = [self XMLRPCArgumentsWithExtraDefaults:extraDefaults andExtra:content]; + + [self.api callMethod:@"wp.editPost" + parameters:parameters + success:^(id responseObject, NSHTTPURLResponse *httpResponse) { + BOOL updated = [responseObject boolValue]; + if (updated) { + if (success) { + success(media); + } + } else { + if (failure) { + NSError *error = [NSError errorWithDomain:NSURLErrorDomain code:NSURLErrorUnknown userInfo:nil]; + failure(error); + } + } + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + +- (void)deleteMedia:(RemoteMedia *)media + success:(void (^)(void))success + failure:(void (^)(NSError *))failure +{ + NSParameterAssert([media.mediaID longLongValue] > 0); + + NSArray *parameters = [self XMLRPCArgumentsWithExtra:media.mediaID]; + [self.api callMethod:@"wp.deleteFile" + parameters:parameters + success:^(id responseObject, NSHTTPURLResponse *httpResponse) { + BOOL deleted = [responseObject boolValue]; + if (deleted) { + if (success) { + success(); + } + } else { + if (failure) { + NSError *error = [NSError errorWithDomain:NSURLErrorDomain code:NSURLErrorUnknown userInfo:nil]; + failure(error); + } + } + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + +-(void)getMetadataFromVideoPressID:(NSString *)videoPressID + isSitePrivate:(BOOL)includeToken + success:(void (^)(RemoteVideoPressVideo *video))success + failure:(void (^)(NSError *))failure +{ + // ⚠️ The endpoint used for fetching the metadata is not available in XML-RPC. + if (failure) { + NSError *error = [NSError errorWithDomain:NSURLErrorDomain + code:NSURLErrorUnsupportedURL + userInfo:nil]; + failure(error); + } +} + +-(void)getVideoPressToken:(NSString *)videoPressID + success:(void (^)(NSString *token))success + failure:(void (^)(NSError *))failure +{ + // The endpoint `wpcom/v2/sites//media/videopress-playback-jwt/` is not available in XML-RPC. + if (failure) { + NSError *error = [NSError errorWithDomain:NSURLErrorDomain + code:NSURLErrorUnsupportedURL + userInfo:nil]; + failure(error); + } +} + +#pragma mark - Private methods + +- (NSArray *)remoteMediaFromXMLRPCArray:(NSArray *)xmlrpcArray +{ + return [xmlrpcArray wpkit_map:^id(NSDictionary *xmlrpcMedia) { + return [self remoteMediaFromXMLRPCDictionary:xmlrpcMedia]; + }]; +} + +- (RemoteMedia *)remoteMediaFromXMLRPCDictionary:(NSDictionary*)xmlRPC +{ + RemoteMedia * remoteMedia = [[RemoteMedia alloc] init]; + remoteMedia.url = [NSURL URLWithString:[xmlRPC stringForKey:@"link"]] ?: [NSURL URLWithString:[xmlRPC stringForKey:@"url"]]; + remoteMedia.title = [xmlRPC stringForKey:@"title"]; + remoteMedia.width = [xmlRPC numberForKeyPath:@"metadata.width"]; + remoteMedia.height = [xmlRPC numberForKeyPath:@"metadata.height"]; + remoteMedia.mediaID = [xmlRPC numberForKey:@"attachment_id"] ?: [xmlRPC numberForKey:@"id"]; + remoteMedia.mimeType = [xmlRPC stringForKeyPath:@"metadata.mime_type"] ?: [xmlRPC stringForKey:@"type"]; + NSString *link = nil; + if ([[xmlRPC objectForKeyPath:@"link"] isKindOfClass:NSDictionary.class]) { + NSDictionary *linkDictionary = (NSDictionary *)[xmlRPC objectForKeyPath:@"link"]; + link = [linkDictionary stringForKeyPath:@"url"]; + } else { + link = [xmlRPC stringForKeyPath:@"link"]; + } + remoteMedia.file = [link lastPathComponent] ?: [[xmlRPC objectForKeyPath:@"file"] lastPathComponent]; + + if ([xmlRPC stringForKeyPath:@"metadata.sizes.large.file"] != nil) { + remoteMedia.largeURL = [NSURL URLWithString: [NSString stringWithFormat:@"%@%@", remoteMedia.url.URLByDeletingLastPathComponent, [xmlRPC stringForKeyPath:@"metadata.sizes.large.file"]]]; + } + + if ([xmlRPC stringForKeyPath:@"metadata.sizes.medium.file"] != nil) { + remoteMedia.mediumURL = [NSURL URLWithString: [NSString stringWithFormat:@"%@%@", remoteMedia.url.URLByDeletingLastPathComponent, [xmlRPC stringForKeyPath:@"metadata.sizes.medium.file"]]]; + } + + if (xmlRPC[@"date_created_gmt"] != nil) { + remoteMedia.date = xmlRPC[@"date_created_gmt"]; + } + + remoteMedia.caption = [xmlRPC stringForKey:@"caption"]; + remoteMedia.descriptionText = [xmlRPC stringForKey:@"description"]; + // Sergio (2017-10-26): This field isn't returned by the XMLRPC API so we assuming empty string + remoteMedia.alt = @""; + remoteMedia.extension = [remoteMedia.file pathExtension]; + remoteMedia.length = [xmlRPC numberForKeyPath:@"metadata.length"]; + + NSNumber *parent = [xmlRPC numberForKeyPath:@"parent"]; + if ([parent integerValue] > 0) { + remoteMedia.postID = parent; + } + + return remoteMedia; +} + +@end diff --git a/Modules/Sources/WordPressKitObjC/MenusServiceRemote.m b/Modules/Sources/WordPressKitObjC/MenusServiceRemote.m new file mode 100644 index 000000000000..a7ddd96f0853 --- /dev/null +++ b/Modules/Sources/WordPressKitObjC/MenusServiceRemote.m @@ -0,0 +1,424 @@ +#import "MenusServiceRemote.h" +#import "WPMapFilterReduce.h" +#import "WPKitLogging.h" + +@import WordPressKitModels; +@import NSObject_SafeExpectations; + +NS_ASSUME_NONNULL_BEGIN + +NSString * const MenusRemoteKeyID = @"id"; +NSString * const MenusRemoteKeyMenu = @"menu"; +NSString * const MenusRemoteKeyMenus = @"menus"; +NSString * const MenusRemoteKeyLocations = @"locations"; +NSString * const MenusRemoteKeyContentID = @"content_id"; +NSString * const MenusRemoteKeyDescription = @"description"; +NSString * const MenusRemoteKeyLinkTarget = @"link_target"; +NSString * const MenusRemoteKeyLinkTitle = @"link_title"; +NSString * const MenusRemoteKeyName = @"name"; +NSString * const MenusRemoteKeyType = @"type"; +NSString * const MenusRemoteKeyTypeFamily = @"type_family"; +NSString * const MenusRemoteKeyTypeLabel = @"type_label"; +NSString * const MenusRemoteKeyURL = @"url"; +NSString * const MenusRemoteKeyItems = @"items"; +NSString * const MenusRemoteKeyDeleted = @"deleted"; +NSString * const MenusRemoteKeyLocationDefaultState = @"defaultState"; +NSString * const MenusRemoteKeyClasses = @"classes"; + +@implementation MenusServiceRemote + +#pragma mark - Remote queries: Creating and modifying menus + +- (void)createMenuWithName:(NSString *)menuName + siteID:(NSNumber *)siteID + success:(nullable MenusServiceRemoteMenuRequestSuccessBlock)success + failure:(nullable MenusServiceRemoteFailureBlock)failure +{ + NSParameterAssert([siteID isKindOfClass:[NSNumber class]]); + NSParameterAssert([menuName isKindOfClass:[NSString class]]); + + NSString *path = [NSString stringWithFormat:@"sites/%@/menus/new", siteID]; + NSString *requestURL = [self pathForEndpoint:path + withVersion:WordPressComRESTAPIVersion_1_1]; + + [self.wordPressComRESTAPI post:requestURL + parameters:@{MenusRemoteKeyName: menuName} + success:^(id _Nonnull responseObject, NSHTTPURLResponse *httpResponse) { + void(^responseFailure)(void) = ^() { + NSString *message = NSLocalizedString(@"An error occurred creating the Menu.", @"An error description explaining that a Menu could not be created."); + [self handleResponseErrorWithMessage:message url:requestURL failure:failure]; + }; + NSNumber *menuID = [responseObject numberForKey:MenusRemoteKeyID]; + if (!menuID) { + responseFailure(); + return; + } + if (success) { + RemoteMenu *menu = [RemoteMenu new]; + menu.menuID = menuID; + menu.name = menuName; + success(menu); + } + } failure:^(NSError * _Nonnull error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + +- (void)updateMenuForID:(NSNumber *)menuID + siteID:(NSNumber *)siteID + withName:(nullable NSString *)updatedName + withLocations:(nullable NSArray *)locationNames + withItems:(nullable NSArray *)updatedItems + success:(nullable MenusServiceRemoteMenuRequestSuccessBlock)success + failure:(nullable MenusServiceRemoteFailureBlock)failure +{ + NSParameterAssert([siteID isKindOfClass:[NSNumber class]]); + NSParameterAssert([menuID isKindOfClass:[NSNumber class]]); + + NSString *path = [NSString stringWithFormat:@"sites/%@/menus/%@", siteID, menuID]; + NSString *requestURL = [self pathForEndpoint:path + withVersion:WordPressComRESTAPIVersion_1_1]; + + NSMutableDictionary *params = [NSMutableDictionary dictionaryWithCapacity:2]; + if (updatedName.length) { + [params setObject:updatedName forKey:MenusRemoteKeyName]; + } + if (updatedItems.count) { + [params setObject:[self menuItemJSONDictionariesFromMenuItems:updatedItems] forKey:MenusRemoteKeyItems]; + } + if (locationNames.count) { + [params setObject:locationNames forKey:MenusRemoteKeyLocations]; + } + + // temporarily need to force the id for the menu update to work until fixed in Jetpack endpoints + // Brent Coursey - 10/1/2015 + [params setObject:menuID forKey:MenusRemoteKeyID]; + + [self.wordPressComRESTAPI post:requestURL + parameters:params + success:^(id _Nonnull responseObject, NSHTTPURLResponse *httpResponse) { + void(^responseFailure)(void) = ^() { + NSString *message = NSLocalizedString(@"An error occurred updating the Menu.", @"An error description explaining that a Menu could not be updated."); + [self handleResponseErrorWithMessage:message url:requestURL failure:failure]; + }; + if (![responseObject isKindOfClass:[NSDictionary class]]) { + responseFailure(); + return; + } + RemoteMenu *menu = [self menuFromJSONDictionary:[responseObject dictionaryForKey:MenusRemoteKeyMenu]]; + if (!menu) { + responseFailure(); + return; + } + if (success) { + success(menu); + } + } failure:^(NSError * _Nonnull error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + +- (void)deleteMenuForID:(NSNumber *)menuID + siteID:(NSNumber *)siteID + success:(nullable MenusServiceRemoteSuccessBlock)success + failure:(nullable MenusServiceRemoteFailureBlock)failure +{ + NSParameterAssert([siteID isKindOfClass:[NSNumber class]]); + NSParameterAssert([menuID isKindOfClass:[NSNumber class]]); + + NSString *path = [NSString stringWithFormat:@"sites/%@/menus/%@/delete", siteID, menuID]; + NSString *requestURL = [self pathForEndpoint:path + withVersion:WordPressComRESTAPIVersion_1_1]; + [self.wordPressComRESTAPI post:requestURL + parameters:nil + success:^(id _Nonnull responseObject, NSHTTPURLResponse *httpResponse) { + void(^responseFailure)(void) = ^() { + NSString *message = NSLocalizedString(@"An error occurred deleting the Menu.", @"An error description explaining that a Menu could not be deleted."); + [self handleResponseErrorWithMessage:message url:requestURL failure:failure]; + }; + if (![responseObject isKindOfClass:[NSDictionary class]]) { + responseFailure(); + return; + } + BOOL deleted = [[responseObject numberForKey:MenusRemoteKeyDeleted] boolValue]; + if (deleted) { + if (success) { + success(); + } + } else { + responseFailure(); + } + } failure:^(NSError * _Nonnull error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + +#pragma mark - Remote queries: Getting menus + +- (void)getMenusForSiteID:(NSNumber *)siteID + success:(nullable MenusServiceRemoteMenusRequestSuccessBlock)success + failure:(nullable MenusServiceRemoteFailureBlock)failure +{ + NSParameterAssert([siteID isKindOfClass:[NSNumber class]]); + + NSString *path = [NSString stringWithFormat:@"sites/%@/menus", siteID]; + NSString *requestURL = [self pathForEndpoint:path + withVersion:WordPressComRESTAPIVersion_1_1]; + + [self.wordPressComRESTAPI get:requestURL + parameters:nil + success:^(id _Nonnull responseObject, NSHTTPURLResponse *httpResponse) { + if (![responseObject isKindOfClass:[NSDictionary class]]) { + NSString *message = NSLocalizedString(@"An error occurred fetching the Menus.", @"An error description explaining that Menus could not be fetched."); + [self handleResponseErrorWithMessage:message url:requestURL failure:failure]; + return; + } + if (success) { + NSArray *menus = [self remoteMenusFromJSONArray:[responseObject arrayForKey:MenusRemoteKeyMenus]]; + NSArray *locations = [self remoteMenuLocationsFromJSONArray:[responseObject arrayForKey:MenusRemoteKeyLocations]]; + success(menus, locations); + } + + } failure:^(NSError * _Nonnull error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + +#pragma mark - Remote Model from JSON + +- (nullable NSArray *)remoteMenusFromJSONArray:(nullable NSArray *)jsonMenus +{ + return [jsonMenus wpkit_map:^id(NSDictionary *dictionary) { + return [self menuFromJSONDictionary:dictionary]; + }]; +} + +- (nullable NSArray *)menuItemsFromJSONDictionaries:(nullable NSArray *)dictionaries parent:(nullable RemoteMenuItem *)parent +{ + NSParameterAssert([dictionaries isKindOfClass:[NSArray class]]); + return [dictionaries wpkit_map:^id(NSDictionary *dictionary) { + + RemoteMenuItem *item = [self menuItemFromJSONDictionary:dictionary]; + item.parentItem = parent; + + return item; + }]; +} + +- (nullable NSArray *)remoteMenuLocationsFromJSONArray:(nullable NSArray *)jsonLocations +{ + return [jsonLocations wpkit_map:^id(NSDictionary *dictionary) { + return [self menuLocationFromJSONDictionary:dictionary]; + }]; +} + +/** + * @brief Creates a remote menu object from the specified dictionary with nested menu items. + * + * @param dictionary The dictionary containing the menu information. Cannot be nil. + * + * @returns A remote menu object. + */ +- (nullable RemoteMenu *)menuFromJSONDictionary:(nullable NSDictionary *)dictionary +{ + NSParameterAssert([dictionary isKindOfClass:[NSDictionary class]]); + if (![dictionary isKindOfClass:[NSDictionary class]]) { + return nil; + } + + NSNumber *menuID = [dictionary numberForKey:MenusRemoteKeyID]; + if (!menuID.integerValue) { + // empty menu dictionary + return nil; + } + + RemoteMenu *menu = [RemoteMenu new]; + menu.menuID = menuID; + menu.details = [dictionary stringForKey:MenusRemoteKeyDescription]; + menu.name = [dictionary stringForKey:MenusRemoteKeyName]; + menu.locationNames = [dictionary arrayForKey:MenusRemoteKeyLocations]; + + NSArray *itemDicts = [dictionary arrayForKey:MenusRemoteKeyItems]; + if (itemDicts.count) { + menu.items = [self menuItemsFromJSONDictionaries:itemDicts parent:nil]; + } + + return menu; +} + +/** + * @brief Creates a remote menu item object from the specified dictionary along with any child items. + * + * @param dictionary The dictionary containing the menu items. Cannot be nil. + * + * @returns A remote menu item object. + */ +- (nullable RemoteMenuItem *)menuItemFromJSONDictionary:(nullable NSDictionary *)dictionary +{ + NSParameterAssert([dictionary isKindOfClass:[NSDictionary class]]); + if (![dictionary isKindOfClass:[NSDictionary class]] || !dictionary.count) { + return nil; + } + + RemoteMenuItem *item = [RemoteMenuItem new]; + item.itemID = [dictionary numberForKey:MenusRemoteKeyID]; + item.contentID = [dictionary numberForKey:MenusRemoteKeyContentID]; + item.details = [dictionary stringForKey:MenusRemoteKeyDescription]; + item.linkTarget = [dictionary stringForKey:MenusRemoteKeyLinkTarget]; + item.linkTitle = [dictionary stringForKey:MenusRemoteKeyLinkTitle]; + item.name = [dictionary stringForKey:MenusRemoteKeyName]; + item.type = [dictionary stringForKey:MenusRemoteKeyType]; + item.typeFamily = [dictionary stringForKey:MenusRemoteKeyTypeFamily]; + item.typeLabel = [dictionary stringForKey:MenusRemoteKeyTypeLabel]; + item.urlStr = [dictionary stringForKey:MenusRemoteKeyURL]; + item.classes = [dictionary arrayForKey:MenusRemoteKeyClasses]; + + NSArray *itemDicts = [dictionary arrayForKey:MenusRemoteKeyItems]; + if (itemDicts.count) { + item.children = [self menuItemsFromJSONDictionaries:itemDicts parent:item]; + } + + return item; +} + +/** + * @brief Creates a remote menu location object from the specified dictionary. + * + * @param dictionary The dictionary containing the locations. Cannot be nil. + * + * @returns A remote menu location object. + */ +- (nullable RemoteMenuLocation *)menuLocationFromJSONDictionary:(nullable NSDictionary *)dictionary +{ + NSParameterAssert([dictionary isKindOfClass:[NSDictionary class]]); + if (![dictionary isKindOfClass:[NSDictionary class]] || !dictionary.count) { + return nil; + } + + RemoteMenuLocation *location = [RemoteMenuLocation new]; + location.defaultState = [dictionary stringForKey:MenusRemoteKeyLocationDefaultState]; + location.details = [dictionary stringForKey:MenusRemoteKeyDescription]; + location.name = [dictionary stringForKey:MenusRemoteKeyName]; + + return location; +} + +#pragma mark - Remote model to JSON + +/** + * @brief Creates remote menu item JSON dictionaries from the remote menu item objects. + * + * @param menuItems The array containing the menu items. Cannot be nil. + * + * @returns An array with menu item JSON dictionary representations. + */ +- (NSArray *)menuItemJSONDictionariesFromMenuItems:(NSArray *)menuItems +{ + NSMutableArray *dictionaries = [NSMutableArray arrayWithCapacity:menuItems.count]; + for (RemoteMenuItem *item in menuItems) { + [dictionaries addObject:[self menuItemJSONDictionaryFromItem:item]]; + } + + return [NSArray arrayWithArray:dictionaries]; +} + +/** + * @brief Creates a remote menu item JSON dictionary from the remote menu item object, with nested item dictionaries. + * + * @param item The remote menu item object. Cannot be nil. + * + * @returns A JSON dictionary representation of the menu item object. + */ +- (NSDictionary *)menuItemJSONDictionaryFromItem:(RemoteMenuItem *)item +{ + NSMutableDictionary *dictionary = [NSMutableDictionary dictionary]; + + if (item.itemID.integerValue) { + dictionary[MenusRemoteKeyID] = item.itemID; + } + + if (item.contentID.integerValue) { + dictionary[MenusRemoteKeyContentID] = item.contentID; + } + + if (item.details.length) { + dictionary[MenusRemoteKeyDescription] = item.details; + } + + if (item.linkTarget.length) { + dictionary[MenusRemoteKeyLinkTarget] = item.linkTarget; + } + + if (item.linkTitle.length) { + dictionary[MenusRemoteKeyLinkTitle] = item.linkTitle; + } + + if (item.name.length) { + dictionary[MenusRemoteKeyName] = item.name; + } + + if (item.type.length) { + dictionary[MenusRemoteKeyType] = item.type; + } + + if (item.typeFamily.length) { + dictionary[MenusRemoteKeyTypeFamily] = item.typeFamily; + } + + if (item.typeLabel.length) { + dictionary[MenusRemoteKeyTypeLabel] = item.typeLabel; + } + + if (item.urlStr.length) { + dictionary[MenusRemoteKeyURL] = item.urlStr; + } + + if (item.classes.count) { + dictionary[MenusRemoteKeyClasses] = item.classes; + } + + if (item.children.count) { + + NSMutableArray *dictionaryItems = [NSMutableArray arrayWithCapacity:item.children.count]; + for (RemoteMenuItem *remoteItem in item.children) { + [dictionaryItems addObject:[self menuItemJSONDictionaryFromItem:remoteItem]]; + } + + dictionary[MenusRemoteKeyItems] = [NSArray arrayWithArray:dictionaryItems]; + } + + return [NSDictionary dictionaryWithDictionary:dictionary]; +} + +- (NSDictionary *)menuLocationJSONDictionaryFromLocation:(RemoteMenuLocation *)location +{ + NSMutableDictionary *dictionary = [NSMutableDictionary dictionary]; + [dictionary setObject:MenusRemoteKeyName forKey:location.name]; + + return [NSDictionary dictionaryWithDictionary:dictionary]; +} + +#pragma mark - errors + +- (void)handleResponseErrorWithMessage:(NSString *)message url:(NSString *)urlStr failure:(nullable MenusServiceRemoteFailureBlock)failure +{ + WPKitLogError(@"%@ - URL: %@", message, urlStr); + NSError *error = [NSError errorWithDomain:NSURLErrorDomain + code:NSURLErrorBadServerResponse + userInfo:@{NSLocalizedDescriptionKey: message}]; + if (failure) { + failure(error); + } +} + +@end + +NS_ASSUME_NONNULL_END diff --git a/Modules/Sources/WordPressKitObjC/NSBundle+VersionNumberHelper.m b/Modules/Sources/WordPressKitObjC/NSBundle+VersionNumberHelper.m new file mode 100644 index 000000000000..736c2bdf1f41 --- /dev/null +++ b/Modules/Sources/WordPressKitObjC/NSBundle+VersionNumberHelper.m @@ -0,0 +1,11 @@ +#import "NSBundle+VersionNumberHelper.h" + +@implementation NSBundle (WPKitVersionNumberHelper) + +- (NSString *)wpkit_bundleVersion +{ + NSDictionary *infoDictionary = [[NSBundle mainBundle] infoDictionary]; + return infoDictionary[(NSString *)kCFBundleVersionKey] ?: [NSString new]; +} + +@end diff --git a/Modules/Sources/WordPressKitObjC/NSMutableDictionary+Helpers.m b/Modules/Sources/WordPressKitObjC/NSMutableDictionary+Helpers.m new file mode 100644 index 000000000000..214b6829c90a --- /dev/null +++ b/Modules/Sources/WordPressKitObjC/NSMutableDictionary+Helpers.m @@ -0,0 +1,10 @@ +#import "NSMutableDictionary+Helpers.h" + +@implementation NSMutableDictionary (Helpers) +- (void)setValueIfNotNil:(id)value forKey:(NSString *)key +{ + if (value != nil) { + self[key] = value; + } +} +@end diff --git a/Modules/Sources/WordPressKitObjC/PostServiceRemoteREST.m b/Modules/Sources/WordPressKitObjC/PostServiceRemoteREST.m new file mode 100644 index 000000000000..d8b967fe4a0c --- /dev/null +++ b/Modules/Sources/WordPressKitObjC/PostServiceRemoteREST.m @@ -0,0 +1,613 @@ +#import "PostServiceRemoteREST.h" +#import "RemotePost.h" +#import "RemotePostCategory.h" +#import "FilePart.h" +#import "WPMapFilterReduce.h" +#import "DisplayableImageHelper.h" +#import "NSString+Helpers.h" + +@import WordPressKitModels; +@import NSObject_SafeExpectations; + +NSString * const PostRemoteStatusPublish = @"publish"; +NSString * const PostRemoteStatusScheduled = @"future"; + +static NSString * const RemoteOptionKeyNumber = @"number"; +static NSString * const RemoteOptionKeyOffset = @"offset"; +static NSString * const RemoteOptionKeyOrder = @"order"; +static NSString * const RemoteOptionKeyOrderBy = @"order_by"; +static NSString * const RemoteOptionKeyStatus = @"status"; +static NSString * const RemoteOptionKeySearch = @"search"; +static NSString * const RemoteOptionKeyAuthor = @"author"; +static NSString * const RemoteOptionKeyMeta = @"meta"; +static NSString * const RemoteOptionKeyTag = @"tag"; + +static NSString * const RemoteOptionValueOrderAscending = @"ASC"; +static NSString * const RemoteOptionValueOrderDescending = @"DESC"; +static NSString * const RemoteOptionValueOrderByDate = @"date"; +static NSString * const RemoteOptionValueOrderByModified = @"modified"; +static NSString * const RemoteOptionValueOrderByTitle = @"title"; +static NSString * const RemoteOptionValueOrderByCommentCount = @"comment_count"; +static NSString * const RemoteOptionValueOrderByPostID = @"ID"; + +@implementation PostServiceRemoteREST + +- (void)getPostWithID:(NSNumber *)postID + success:(void (^)(RemotePost *post))success + failure:(void (^)(NSError *))failure +{ + NSParameterAssert(postID); + + NSString *path = [NSString stringWithFormat:@"sites/%@/posts/%@", self.siteID, postID]; + NSString *requestUrl = [self pathForEndpoint:path + withVersion:WordPressComRESTAPIVersion_1_1]; + + NSDictionary *parameters = @{ @"context": @"edit" }; + + [self.wordPressComRESTAPI get:requestUrl + parameters:parameters + success:^(id responseObject, NSHTTPURLResponse *httpResponse) { + if (success) { + success([self remotePostFromJSONDictionary:responseObject]); + } + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + +- (void)getPostsOfType:(NSString *)postType + success:(void (^)(NSArray *remotePosts))success + failure:(void (^)(NSError *))failure +{ + [self getPostsOfType:postType options:nil success:success failure:failure]; +} + +- (void)getPostsOfType:(NSString *)postType + options:(NSDictionary *)options + success:(void (^)(NSArray *remotePosts))success + failure:(void (^)(NSError *))failure +{ + NSParameterAssert([postType isKindOfClass:[NSString class]]); + + NSString *path = [NSString stringWithFormat:@"sites/%@/posts", self.siteID]; + NSString *requestUrl = [self pathForEndpoint:path + withVersion:WordPressComRESTAPIVersion_1_2]; + + NSDictionary *parameters = @{ + @"status": @"any,trash", + @"context": @"edit", + @"number": @40, + @"type": postType, + }; + if (options) { + NSMutableDictionary *mutableParameters = [parameters mutableCopy]; + [mutableParameters addEntriesFromDictionary:options]; + parameters = [NSDictionary dictionaryWithDictionary:mutableParameters]; + } + [self.wordPressComRESTAPI get:requestUrl + parameters:parameters + success:^(id responseObject, NSHTTPURLResponse *httpResponse) { + if (success) { + success([self remotePostsFromJSONArray:responseObject[@"posts"]]); + } + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + +-(void)getAutoSaveForPost:(RemotePost *)post + success:(void (^)(RemotePost *))success + failure:(void (^)(NSError *error))failure +{ + NSString *path = [NSString stringWithFormat:@"sites/%@/posts/%@/autosave", self.siteID, post.postID]; + NSString *requestUrl = [self pathForEndpoint:path + withVersion:WordPressComRESTAPIVersion_1_1]; + + NSDictionary *parameters = [self parametersWithRemotePost:post]; + + [self.wordPressComRESTAPI get:requestUrl + parameters:parameters + success:^(id responseObject, NSHTTPURLResponse *httpResponse) { + RemotePost *post = [self remotePostFromJSONDictionary:responseObject]; + if (success) { + success(post); + } + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + +- (void)createPost:(RemotePost *)post + success:(void (^)(RemotePost *))success + failure:(void (^)(NSError *))failure +{ + NSParameterAssert([post isKindOfClass:[RemotePost class]]); + + NSString *path = [NSString stringWithFormat:@"sites/%@/posts/new?context=edit", self.siteID]; + NSString *requestUrl = [self pathForEndpoint:path + withVersion:WordPressComRESTAPIVersion_1_2]; + + NSDictionary *parameters = [self parametersWithRemotePost:post]; + + [self.wordPressComRESTAPI post:requestUrl + parameters:parameters + success:^(id responseObject, NSHTTPURLResponse *httpResponse) { + RemotePost *post = [self remotePostFromJSONDictionary:responseObject]; + if (success) { + success(post); + } + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + +- (void)createPost:(RemotePost *)post + withMedia:(RemoteMedia *)media + requestEnqueued:(void (^)(NSNumber *taskID))requestEnqueued + success:(void (^)(RemotePost *))success + failure:(void (^)(NSError *))failure +{ + NSParameterAssert([post isKindOfClass:[RemotePost class]]); + + NSString *type = media.mimeType; + NSString *filename = media.file; + NSString *path = [NSString stringWithFormat:@"sites/%@/posts/new", self.siteID]; + NSString *requestUrl = [self pathForEndpoint:path + withVersion:WordPressComRESTAPIVersion_1_2]; + + NSMutableDictionary *parameters = [NSMutableDictionary dictionaryWithDictionary:@{}]; + parameters[@"content"] = post.content; + parameters[@"title"] = post.title; + parameters[@"status"] = post.status; + FilePart *filePart = [[FilePart alloc] initWithParameterName:@"media[]" url:media.localURL fileName:filename mimeType:type]; + [self.wordPressComRESTAPI multipartPOST:requestUrl + parameters:parameters + fileParts:@[filePart] + requestEnqueued:^(NSNumber *taskID) { + if (requestEnqueued) { + requestEnqueued(taskID); + } + } success:^(id _Nonnull responseObject, NSHTTPURLResponse * _Nullable httpResponse) { + RemotePost *post = [self remotePostFromJSONDictionary:responseObject]; + if (success) { + success(post); + } + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + +- (void)updatePost:(RemotePost *)post + success:(void (^)(RemotePost *))success + failure:(void (^)(NSError *))failure +{ + NSParameterAssert([post isKindOfClass:[RemotePost class]]); + + NSString *path = [NSString stringWithFormat:@"sites/%@/posts/%@?context=edit", self.siteID, post.postID]; + NSString *requestUrl = [self pathForEndpoint:path + withVersion:WordPressComRESTAPIVersion_1_2]; + + NSDictionary *parameters = [self parametersWithRemotePost:post]; + + [self.wordPressComRESTAPI post:requestUrl + parameters:parameters + success:^(id responseObject, NSHTTPURLResponse *httpResponse) { + RemotePost *post = [self remotePostFromJSONDictionary:responseObject]; + if (success) { + success(post); + } + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + +- (void)autoSave:(RemotePost *)post + success:(void (^)(RemotePost *, NSString *))success + failure:(void (^)(NSError *))failure +{ + NSParameterAssert([post isKindOfClass:[RemotePost class]]); + + NSString *path = [NSString stringWithFormat:@"sites/%@/posts/%@/autosave", self.siteID, post.postID]; + NSString *requestUrl = [self pathForEndpoint:path + withVersion:WordPressComRESTAPIVersion_1_1]; + + NSDictionary *parameters = [self parametersWithRemotePost:post]; + + [self.wordPressComRESTAPI post:requestUrl + parameters:parameters + success:^(id responseObject, NSHTTPURLResponse *httpResponse) { + RemotePost *post = [self remotePostFromJSONDictionary:responseObject]; + NSString *previewURL = responseObject[@"preview_URL"]; + if (success) { + success(post, previewURL); + } + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + +- (void)deletePost:(RemotePost *)post + success:(void (^)(void))success + failure:(void (^)(NSError *))failure +{ + NSParameterAssert([post isKindOfClass:[RemotePost class]]); + + NSString *path = [NSString stringWithFormat:@"sites/%@/posts/%@/delete", self.siteID, post.postID]; + NSString *requestUrl = [self pathForEndpoint:path + withVersion:WordPressComRESTAPIVersion_1_1]; + + [self.wordPressComRESTAPI post:requestUrl + parameters:nil + success:^(id responseObject, NSHTTPURLResponse *httpResponse) { + if (success) { + success(); + } + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + +- (void)trashPost:(RemotePost *)post + success:(void (^)(RemotePost *))success + failure:(void (^)(NSError *))failure +{ + NSParameterAssert([post isKindOfClass:[RemotePost class]]); + + // The parameters are passed as part of the string here because AlamoFire doesn't encode parameters on POST requests. + NSString *path = [NSString stringWithFormat:@"sites/%@/posts/%@/delete?context=edit", self.siteID, post.postID]; + NSString *requestUrl = [self pathForEndpoint:path + withVersion:WordPressComRESTAPIVersion_1_1]; + + [self.wordPressComRESTAPI post:requestUrl + parameters:nil + success:^(id responseObject, NSHTTPURLResponse *httpResponse) { + RemotePost *post = [self remotePostFromJSONDictionary:responseObject]; + if (success) { + success(post); + } + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + +- (void)restorePost:(RemotePost *)post + success:(void (^)(RemotePost *))success + failure:(void (^)(NSError *))failure +{ + NSParameterAssert([post isKindOfClass:[RemotePost class]]); + + // The parameters are passed as part of the string here because AlamoFire doesn't encode parameters on POST requests. + // https://github.com/wordpress-mobile/WordPressKit-iOS/pull/385 + NSString *path = [NSString stringWithFormat:@"sites/%@/posts/%@/restore?context=edit", self.siteID, post.postID]; + NSString *requestUrl = [self pathForEndpoint:path + withVersion:WordPressComRESTAPIVersion_1_1]; + + [self.wordPressComRESTAPI post:requestUrl + parameters:nil + success:^(id responseObject, NSHTTPURLResponse *httpResponse) { + RemotePost *post = [self remotePostFromJSONDictionary:responseObject]; + if (success) { + success(post); + } + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + +- (NSDictionary *)dictionaryWithRemoteOptions:(id )options +{ + NSMutableDictionary *remoteParams = [NSMutableDictionary dictionary]; + if (options.number) { + [remoteParams setObject:options.number forKey:RemoteOptionKeyNumber]; + } + if (options.offset) { + [remoteParams setObject:options.offset forKey:RemoteOptionKeyOffset]; + } + + NSString *statusesStr = nil; + if (options.statuses.count) { + statusesStr = [options.statuses componentsJoinedByString:@","]; + } + if (options.order) { + NSString *orderStr = nil; + switch (options.order) { + case PostServiceResultsOrderDescending: + orderStr = RemoteOptionValueOrderDescending; + break; + case PostServiceResultsOrderAscending: + orderStr = RemoteOptionValueOrderAscending; + break; + } + [remoteParams setObject:orderStr forKey:RemoteOptionKeyOrder]; + } + + NSString *orderByStr = nil; + if (options.orderBy) { + switch (options.orderBy) { + case PostServiceResultsOrderingByDate: + orderByStr = RemoteOptionValueOrderByDate; + break; + case PostServiceResultsOrderingByModified: + orderByStr = RemoteOptionValueOrderByModified; + break; + case PostServiceResultsOrderingByTitle: + orderByStr = RemoteOptionValueOrderByTitle; + break; + case PostServiceResultsOrderingByCommentCount: + orderByStr = RemoteOptionValueOrderByCommentCount; + break; + case PostServiceResultsOrderingByPostID: + orderByStr = RemoteOptionValueOrderByPostID; + break; + } + } + + if (statusesStr.length) { + [remoteParams setObject:statusesStr forKey:RemoteOptionKeyStatus]; + } + if (orderByStr.length) { + [remoteParams setObject:orderByStr forKey:RemoteOptionKeyOrderBy]; + } + if (options.authorID) { + [remoteParams setObject:options.authorID forKey:RemoteOptionKeyAuthor]; + } + if (options.search.length > 0) { + [remoteParams setObject:options.search forKey:RemoteOptionKeySearch]; + } + if (options.meta.length > 0) { + [remoteParams setObject:options.meta forKey:RemoteOptionKeyMeta]; + } + if ([options respondsToSelector:@selector(tag)] && options.tag.length > 0) { + [remoteParams setObject:options.tag forKey:RemoteOptionKeyTag]; + } + + return remoteParams.count ? [NSDictionary dictionaryWithDictionary:remoteParams] : nil; +} + +#pragma mark - Private methods + +- (NSArray *)remotePostsFromJSONArray:(NSArray *)jsonPosts { + return [jsonPosts wpkit_map:^id(NSDictionary *jsonPost) { + return [self remotePostFromJSONDictionary:jsonPost]; + }]; +} + +- (RemotePost *)remotePostFromJSONDictionary:(NSDictionary *)jsonPost { + return [PostServiceRemoteREST remotePostFromJSONDictionary:jsonPost]; +} + ++ (RemotePost *)remotePostFromJSONDictionary:(NSDictionary *)jsonPost { + RemotePost *post = [RemotePost new]; + post.postID = jsonPost[@"ID"]; + post.siteID = jsonPost[@"site_ID"]; + if (jsonPost[@"author"] != [NSNull null]) { + NSDictionary *authorDictionary = jsonPost[@"author"]; + post.authorAvatarURL = authorDictionary[@"avatar_URL"]; + post.authorDisplayName = authorDictionary[@"name"]; + post.authorEmail = [authorDictionary stringForKey:@"email"]; + post.authorURL = authorDictionary[@"URL"]; + } + post.authorID = [jsonPost numberForKeyPath:@"author.ID"]; + post.date = [NSDate dateWithWordPressComJSONString:jsonPost[@"date"]]; + post.dateModified = [NSDate dateWithWordPressComJSONString:jsonPost[@"modified"]]; + post.title = jsonPost[@"title"]; + post.URL = [NSURL URLWithString:jsonPost[@"URL"]]; + post.shortURL = [NSURL URLWithString:jsonPost[@"short_URL"]]; + post.content = jsonPost[@"content"]; + post.excerpt = jsonPost[@"excerpt"]; + post.slug = jsonPost[@"slug"]; + post.suggestedSlug = [jsonPost stringForKeyPath:@"other_URLs.suggested_slug"]; + post.status = jsonPost[@"status"]; + post.password = jsonPost[@"password"]; + if ([post.password wpkit_isEmpty]) { + post.password = nil; + } + post.parentID = [jsonPost numberForKeyPath:@"parent.ID"]; + // post_thumbnail can be null, which will transform to NSNull, so we need to add the extra check + NSDictionary *postThumbnail = [jsonPost dictionaryForKey:@"post_thumbnail"]; + post.postThumbnailID = [postThumbnail numberForKey:@"ID"]; + post.postThumbnailPath = [postThumbnail stringForKeyPath:@"URL"]; + post.type = jsonPost[@"type"]; + post.format = jsonPost[@"format"]; + post.order = [jsonPost numberForKey:@"menu_order"].integerValue; + + post.commentCount = [jsonPost numberForKeyPath:@"discussion.comment_count"] ?: @0; + post.likeCount = [jsonPost numberForKeyPath:@"like_count"] ?: @0; + + post.isStickyPost = [jsonPost numberForKeyPath:@"sticky"]; + + // FIXME: remove conversion once API is fixed #38-io + // metadata should always be an array but it's returning false when there are no custom fields + post.metadata = [jsonPost arrayForKey:@"metadata"]; + // Or even worse, in some cases (Jetpack sites?) is an array containing false + post.metadata = [post.metadata filteredArrayUsingPredicate:[NSPredicate predicateWithBlock:^BOOL(id evaluatedObject, NSDictionary *bindings) { + return [evaluatedObject isKindOfClass:[NSDictionary class]]; + }]]; + // post.metadata = jsonPost[@"metadata"]; + + NSDictionary *categories = jsonPost[@"categories"]; + if (categories) { + post.categories = [self remoteCategoriesFromJSONArray:[categories allValues]]; + } + post.tags = [self tagNamesFromJSONDictionary:jsonPost[@"tags"]]; + + post.revisions = [jsonPost arrayForKey:@"revisions"]; + + NSDictionary *autosaveAttributes = jsonPost[@"meta"][@"data"][@"autosave"]; + if ([autosaveAttributes wp_isValidObject]) { + RemotePostAutosave *autosave = [[RemotePostAutosave alloc] init]; + autosave.title = autosaveAttributes[@"title"]; + autosave.content = autosaveAttributes[@"content"]; + autosave.excerpt = autosaveAttributes[@"excerpt"]; + autosave.modifiedDate = [NSDate dateWithWordPressComJSONString:autosaveAttributes[@"modified"]]; + autosave.identifier = autosaveAttributes[@"ID"]; + autosave.authorID = autosaveAttributes[@"author_ID"]; + autosave.postID = autosaveAttributes[@"post_ID"]; + autosave.previewURL = autosaveAttributes[@"preview_URL"]; + post.autosave = autosave; + } + + // Pick an image to use for display + if (post.postThumbnailPath) { + post.pathForDisplayImage = post.postThumbnailPath; + } else { + // parse contents for a suitable image + post.pathForDisplayImage = [WPKitDisplayableImageHelper searchPostContentForImageToDisplay:post.content]; + if ([post.pathForDisplayImage length] == 0) { + post.pathForDisplayImage = [WPKitDisplayableImageHelper searchPostAttachmentsForImageToDisplay:[jsonPost dictionaryForKey:@"attachments"] existingInContent:post.content]; + } + } + + return post; +} + +- (NSDictionary *)parametersWithRemotePost:(RemotePost *)post +{ + NSParameterAssert(post.title != nil); + NSParameterAssert(post.content != nil); + BOOL existingPost = ([post.postID longLongValue] > 0); + NSMutableDictionary *parameters = [NSMutableDictionary dictionary]; + + if (post.title) { + parameters[@"title"] = post.title; + } else { + parameters[@"title"] = @""; + } + + parameters[@"content"] = post.content; + parameters[@"password"] = post.password ? post.password : @""; + parameters[@"type"] = post.type; + + if (post.date) { + parameters[@"date"] = [post.date WordPressComJSONString]; + } else if (existingPost) { + // safety net. An existing post with no create date should publish immediately + parameters[@"date"] = [[NSDate date] WordPressComJSONString]; + } + if (post.excerpt) { + parameters[@"excerpt"] = post.excerpt; + } + if (post.slug) { + parameters[@"slug"] = post.slug; + } + + if (post.authorID) { + parameters[@"author"] = post.authorID; + } + + if (post.categories) { + parameters[@"categories_by_id"] = [post.categories valueForKey:@"categoryID"]; + } + + if (post.tags) { + NSArray *tags = post.tags; + NSDictionary *postTags = @{@"post_tag":tags}; + parameters[@"terms"] = postTags; + } + if (post.format) { + parameters[@"format"] = post.format; + } + + parameters[@"parent"] = post.parentID ?: @"false"; + parameters[@"featured_image"] = post.postThumbnailID ? [post.postThumbnailID stringValue] : @""; + + NSArray *metadata = [self metadataForPost:post]; + if (metadata.count > 0) { + parameters[@"metadata"] = metadata; + } + + if (post.isStickyPost != nil) { + parameters[@"sticky"] = post.isStickyPost.boolValue ? @"true" : @"false"; + } + + // Scheduled posts need to sync with a status of 'publish'. + // Passing a status of 'future' will set the post status to 'draft' + // This is an apparent inconsistency in the API as 'future' should + // be a valid status. + if ([post.status isEqualToString:PostRemoteStatusScheduled]) { + post.status = PostRemoteStatusPublish; + } + parameters[@"status"] = post.status; + + // Test what happens for nil and not present values + return [NSDictionary dictionaryWithDictionary:parameters]; +} + +- (NSArray *)metadataForPost:(RemotePost *)post { + return [post.metadata wpkit_map:^id(NSDictionary *meta) { + NSNumber *metaID = [meta objectForKey:@"id"]; + NSString *metaValue = [meta objectForKey:@"value"]; + NSString *metaKey = [meta objectForKey:@"key"]; + NSString *operation = @"update"; + + if (!metaKey) { + if (metaID && !metaValue) { + operation = @"delete"; + } else if (!metaID && metaValue) { + operation = @"add"; + } + } + + NSMutableDictionary *modifiedMeta = [meta mutableCopy]; + modifiedMeta[@"operation"] = operation; + return [NSDictionary dictionaryWithDictionary:modifiedMeta]; + }]; +} + ++ (NSArray *)remoteCategoriesFromJSONArray:(NSArray *)jsonCategories { + return [jsonCategories wpkit_map:^id(NSDictionary *jsonCategory) { + return [self remoteCategoryFromJSONDictionary:jsonCategory]; + }]; +} + ++ (RemotePostCategory *)remoteCategoryFromJSONDictionary:(NSDictionary *)jsonCategory { + RemotePostCategory *category = [RemotePostCategory new]; + category.categoryID = jsonCategory[@"ID"]; + category.name = jsonCategory[@"name"]; + category.parentID = jsonCategory[@"parent"]; + + return category; +} + ++ (NSArray *)tagNamesFromJSONDictionary:(NSDictionary *)jsonTags { + return [jsonTags allKeys]; +} + +/** + * @brief Returns an array of RemoteLikeUser based on provided JSON + * representation of users. + * + * @param jsonUsers An array containing JSON representations of users. + * @param postID ID of the Post the users liked. + * @param siteID ID of the Post's site. + */ +- (NSArray *)remoteUsersFromJSONArray:(NSArray *)jsonUsers + postID:(NSNumber *)postID + siteID:(NSNumber *)siteID +{ + return [jsonUsers wpkit_map:^id(NSDictionary *jsonUser) { + return [[RemoteLikeUser alloc] initWithDictionary:jsonUser postID:postID siteID:siteID]; + }]; +} + +@end diff --git a/Modules/Sources/WordPressKitObjC/PostServiceRemoteXMLRPC.m b/Modules/Sources/WordPressKitObjC/PostServiceRemoteXMLRPC.m new file mode 100644 index 000000000000..0d21d90bd1de --- /dev/null +++ b/Modules/Sources/WordPressKitObjC/PostServiceRemoteXMLRPC.m @@ -0,0 +1,463 @@ +#import "PostServiceRemoteXMLRPC.h" +#import "RemotePost.h" +#import "RemotePostCategory.h" +#import "NSMutableDictionary+Helpers.h" +#import "NSString+Helpers.h" +#import "WPMapFilterReduce.h" +#import "DisplayableImageHelper.h" + +@import WordPressKitModels; +@import NSObject_SafeExpectations; + +const NSInteger HTTP404ErrorCode = 404; +NSString * const WordPressAppErrorDomain = @"org.wordpress.iphone"; + +static NSString * const RemoteOptionKeyNumber = @"number"; +static NSString * const RemoteOptionKeyOffset = @"offset"; +static NSString * const RemoteOptionKeyOrder = @"order"; +static NSString * const RemoteOptionKeyOrderBy = @"orderby"; +static NSString * const RemoteOptionKeyStatus = @"post_status"; +static NSString * const RemoteOptionKeySearch = @"s"; + +static NSString * const RemoteOptionValueOrderAscending = @"ASC"; +static NSString * const RemoteOptionValueOrderDescending = @"DESC"; +static NSString * const RemoteOptionValueOrderByDate = @"date"; +static NSString * const RemoteOptionValueOrderByModified = @"modified"; +static NSString * const RemoteOptionValueOrderByTitle = @"title"; +static NSString * const RemoteOptionValueOrderByCommentCount = @"comment_count"; +static NSString * const RemoteOptionValueOrderByPostID = @"ID"; + +@implementation PostServiceRemoteXMLRPC + +- (void)getPostWithID:(NSNumber *)postID + success:(void (^)(RemotePost *post))success + failure:(void (^)(NSError *))failure +{ + NSArray *parameters = [self XMLRPCArgumentsWithExtra:postID]; + [self.api callMethod:@"wp.getPost" + parameters:parameters + success:^(id responseObject, NSHTTPURLResponse *httpResponse) { + if (success) { + success([self remotePostFromXMLRPCDictionary:responseObject]); + } + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + +- (void)getPostsOfType:(NSString *)postType + success:(void (^)(NSArray *remotePosts))success + failure:(void (^)(NSError *))failure { + [self getPostsOfType:postType options:nil success:success failure:failure]; +} + +- (void)getPostsOfType:(NSString *)postType + options:(NSDictionary *)options + success:(void (^)(NSArray *remotePosts))success + failure:(void (^)(NSError *error))failure { + NSArray *statuses = @[PostStatusDraft, PostStatusPending, PostStatusPrivate, PostStatusPublish, PostStatusScheduled, PostStatusTrash]; + NSString *postStatus = [statuses componentsJoinedByString:@","]; + NSDictionary *extraParameters = @{ + @"number": @40, + @"post_type": postType, + @"post_status": postStatus, + }; + if (options) { + NSMutableDictionary *mutableParameters = [extraParameters mutableCopy]; + [mutableParameters addEntriesFromDictionary:options]; + extraParameters = [NSDictionary dictionaryWithDictionary:mutableParameters]; + } + NSArray *parameters = [self XMLRPCArgumentsWithExtra:extraParameters]; + [self.api callMethod:@"wp.getPosts" + parameters:parameters + success:^(id responseObject, NSHTTPURLResponse *httpResponse) { + NSAssert([responseObject isKindOfClass:[NSArray class]], @"Response should be an array."); + if (success) { + success([self remotePostsFromXMLRPCArray:responseObject]); + } + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + +- (void)createPost:(RemotePost *)post + success:(void (^)(RemotePost *))success + failure:(void (^)(NSError *))failure +{ + NSDictionary *extraParameters = [self parametersWithRemotePost:post]; + NSArray *parameters = [self XMLRPCArgumentsWithExtra:extraParameters]; + [self.api callMethod:@"metaWeblog.newPost" + parameters:parameters + success:^(id responseObject, NSHTTPURLResponse *httpResponse) { + if ([responseObject respondsToSelector:@selector(wpkit_numericValue)]) { + post.postID = [responseObject wpkit_numericValue]; + + if (!post.date) { + // Set the temporary date until we get it from the server so it sorts properly on the list + post.date = [NSDate date]; + } + + [self getPostWithID:post.postID success:^(RemotePost *fetchedPost) { + if (success) { + success(fetchedPost); + } + } failure:^(NSError *error) { + // update failed, and that sucks, but creating the post succeeded… so, let's just act like everything is ok! + if (success) { + success(post); + } + }]; + } else if (failure) { + NSDictionary *userInfo = @{NSLocalizedDescriptionKey: [NSString stringWithFormat:@"Invalid value returned for new post: %@", responseObject]}; + NSError *error = [NSError errorWithDomain:WordPressAppErrorDomain code:0 userInfo:userInfo]; + failure(error); + } + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + +- (void)updatePost:(RemotePost *)post + success:(void (^)(RemotePost *))success + failure:(void (^)(NSError *))failure +{ + NSParameterAssert(post.postID.integerValue > 0); + + if ([post.postID integerValue] <= 0) { + if (failure) { + NSDictionary *userInfo = @{NSLocalizedDescriptionKey: @"Can't edit a post if it's not in the server"}; + NSError *error = [NSError errorWithDomain:WordPressAppErrorDomain code:0 userInfo:userInfo]; + dispatch_async(dispatch_get_main_queue(), ^{ + failure(error); + }); + } + return; + } + + NSDictionary *extraParameters = [self parametersWithRemotePost:post]; + NSMutableArray *parameters = [NSMutableArray arrayWithArray:[self XMLRPCArgumentsWithExtra:extraParameters]]; + [parameters replaceObjectAtIndex:0 withObject:post.postID]; + [self.api callMethod:@"metaWeblog.editPost" + parameters:parameters + success:^(id responseObject, NSHTTPURLResponse *httpResponse) { + [self getPostWithID:post.postID success:^(RemotePost *fetchedPost) { + if (success) { + success(fetchedPost); + } + } failure:^(NSError *error) { + //We failed to fetch the post but the update was successful + if (success) { + success(post); + } + }]; + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + +- (void)deletePost:(RemotePost *)post + success:(void (^)(void))success + failure:(void (^)(NSError *))failure +{ + NSParameterAssert([post.postID longLongValue] > 0); + NSNumber *postID = post.postID; + if ([postID longLongValue] > 0) { + NSArray *parameters = [self XMLRPCArgumentsWithExtra:postID]; + [self.api callMethod:@"wp.deletePost" + parameters:parameters + success:^(id responseObject, NSHTTPURLResponse *httpResponse) { + if (success) success(); + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) failure(error); + }]; + } +} + +- (void)trashPost:(RemotePost *)post + success:(void (^)(RemotePost *))success + failure:(void (^)(NSError *))failure +{ + NSParameterAssert([post.postID longLongValue] > 0); + NSNumber *postID = post.postID; + if ([postID longLongValue] <= 0) { + return; + } + NSArray *parameters = [self XMLRPCArgumentsWithExtra:postID]; + + [self.api callMethod:@"wp.deletePost" + parameters:parameters + success:^(id responseObject, NSHTTPURLResponse *httpResponse) { + [self.api callMethod:@"wp.getPost" + parameters:parameters + success:^(id responseObject, NSHTTPURLResponse *httpResponse) { + if (success) { + // The post was trashed but not yet deleted. + RemotePost *post = [self remotePostFromXMLRPCDictionary:responseObject]; + success(post); + } + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (httpResponse.statusCode == HTTP404ErrorCode) { + // The post was deleted. + if (success) { + success(post); + } + } + }]; + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + +- (void)restorePost:(RemotePost *)post + success:(void (^)(RemotePost *))success + failure:(void (^)(NSError *error))failure +{ + [self updatePost:post success:success failure:failure]; +} + +- (NSDictionary *)dictionaryWithRemoteOptions:(id )options +{ + NSMutableDictionary *remoteParams = [NSMutableDictionary dictionary]; + if (options.number) { + [remoteParams setObject:options.number forKey:RemoteOptionKeyNumber]; + } + if (options.offset) { + [remoteParams setObject:options.offset forKey:RemoteOptionKeyOffset]; + } + + NSString *statusesStr = nil; + if (options.statuses.count) { + statusesStr = [options.statuses componentsJoinedByString:@","]; + } + if (options.order) { + NSString *orderStr = nil; + switch (options.order) { + case PostServiceResultsOrderDescending: + orderStr = RemoteOptionValueOrderDescending; + break; + case PostServiceResultsOrderAscending: + orderStr = RemoteOptionValueOrderAscending; + break; + } + [remoteParams setObject:orderStr forKey:RemoteOptionKeyOrder]; + } + + NSString *orderByStr = nil; + if (options.orderBy) { + switch (options.orderBy) { + case PostServiceResultsOrderingByDate: + orderByStr = RemoteOptionValueOrderByDate; + break; + case PostServiceResultsOrderingByModified: + orderByStr = RemoteOptionValueOrderByModified; + break; + case PostServiceResultsOrderingByTitle: + orderByStr = RemoteOptionValueOrderByTitle; + break; + case PostServiceResultsOrderingByCommentCount: + orderByStr = RemoteOptionValueOrderByCommentCount; + break; + case PostServiceResultsOrderingByPostID: + orderByStr = RemoteOptionValueOrderByPostID; + break; + } + } + + if (statusesStr.length) { + [remoteParams setObject:statusesStr forKey:RemoteOptionKeyStatus]; + } + if (orderByStr.length) { + [remoteParams setObject:orderByStr forKey:RemoteOptionKeyOrderBy]; + } + + NSString *search = [options search]; + if (search.length) { + [remoteParams setObject:search forKey:RemoteOptionKeySearch]; + } + + return remoteParams.count ? [NSDictionary dictionaryWithDictionary:remoteParams] : nil; +} + +#pragma mark - Private methods + +- (NSArray *)remotePostsFromXMLRPCArray:(NSArray *)xmlrpcArray { + return [xmlrpcArray wpkit_map:^id(NSDictionary *xmlrpcPost) { + return [self remotePostFromXMLRPCDictionary:xmlrpcPost]; + }]; +} + +- (RemotePost *)remotePostFromXMLRPCDictionary:(NSDictionary *)xmlrpcDictionary { + return [PostServiceRemoteXMLRPC remotePostFromXMLRPCDictionary:xmlrpcDictionary]; +} + ++ (RemotePost *)remotePostFromXMLRPCDictionary:(NSDictionary *)xmlrpcDictionary { + RemotePost *post = [RemotePost new]; + + post.postID = [xmlrpcDictionary numberForKey:@"post_id"]; + post.date = xmlrpcDictionary[@"post_date_gmt"]; + post.dateModified = xmlrpcDictionary[@"post_modified_gmt"]; + if (xmlrpcDictionary[@"link"]) { + post.URL = [NSURL URLWithString:xmlrpcDictionary[@"link"]]; + } + post.title = xmlrpcDictionary[@"post_title"]; + post.content = xmlrpcDictionary[@"post_content"]; + post.excerpt = xmlrpcDictionary[@"post_excerpt"]; + post.slug = xmlrpcDictionary[@"post_name"]; + post.authorID = [xmlrpcDictionary numberForKey:@"post_author"]; + post.status = [self statusForPostStatus:xmlrpcDictionary[@"post_status"] andDate:post.date]; + post.password = xmlrpcDictionary[@"post_password"]; + if ([post.password wpkit_isEmpty]) { + post.password = nil; + } + post.parentID = [xmlrpcDictionary numberForKey:@"post_parent"]; + // When there is no featured image, post_thumbnail is an empty array :( + NSDictionary *thumbnailDict = [xmlrpcDictionary dictionaryForKey:@"post_thumbnail"]; + post.postThumbnailID = [thumbnailDict numberForKey:@"attachment_id"]; + post.postThumbnailPath = [thumbnailDict stringForKey:@"link"]; + post.type = xmlrpcDictionary[@"post_type"]; + post.format = xmlrpcDictionary[@"post_format"]; + post.order = [xmlrpcDictionary numberForKey:@"menu_order"].integerValue; + + post.metadata = xmlrpcDictionary[@"custom_fields"]; + + NSArray *terms = [xmlrpcDictionary arrayForKey:@"terms"]; + post.tags = [self tagsFromXMLRPCTermsArray:terms]; + post.categories = [self remoteCategoriesFromXMLRPCTermsArray:terms]; + + post.isStickyPost = [xmlrpcDictionary numberForKeyPath:@"sticky"]; + + // Pick an image to use for display + if (post.postThumbnailPath) { + post.pathForDisplayImage = post.postThumbnailPath; + } else { + // parse content for a suitable image. + post.pathForDisplayImage = [WPKitDisplayableImageHelper searchPostContentForImageToDisplay:post.content]; + } + + return post; +} + ++ (NSString *)statusForPostStatus:(NSString *)status andDate:(NSDate *)date +{ + // Scheduled posts are synced with a post_status of 'publish' but we want to + // work with a status of 'future' from within the app. + if ([status isEqualToString:PostStatusPublish] && date == [date laterDate:[NSDate date]]) { + return PostStatusScheduled; + } + return status; +} + ++ (NSArray *)tagsFromXMLRPCTermsArray:(NSArray *)terms { + NSArray *tags = [terms filteredArrayUsingPredicate:[NSPredicate predicateWithFormat:@"taxonomy = 'post_tag' AND name != NIL"]]; + return [tags valueForKey:@"name"]; +} + ++ (NSArray *)remoteCategoriesFromXMLRPCTermsArray:(NSArray *)terms { + return [[terms wpkit_filter:^BOOL(NSDictionary *category) { + return [[category stringForKey:@"taxonomy"] isEqualToString:@"category"]; + }] wpkit_map:^id(NSDictionary *category) { + return [self remoteCategoryFromXMLRPCDictionary:category]; + }]; +} + ++ (RemotePostCategory *)remoteCategoryFromXMLRPCDictionary:(NSDictionary *)xmlrpcCategory { + RemotePostCategory *category = [RemotePostCategory new]; + category.categoryID = [xmlrpcCategory numberForKey:@"term_id"]; + category.name = [xmlrpcCategory stringForKey:@"name"]; + category.parentID = [xmlrpcCategory numberForKey:@"parent"]; + return category; +} + +- (NSDictionary *)parametersWithRemotePost:(RemotePost *)post +{ + BOOL existingPost = ([post.postID longLongValue] > 0); + NSMutableDictionary *postParams = [NSMutableDictionary dictionary]; + + [postParams setValueIfNotNil:post.type forKey:@"post_type"]; + [postParams setValueIfNotNil:post.title forKey:@"title"]; + [postParams setValueIfNotNil:post.content forKey:@"description"]; + [postParams setValueIfNotNil:post.date forKey:@"date_created_gmt"]; + [postParams setValueIfNotNil:post.password forKey:@"wp_password"]; + [postParams setValueIfNotNil:[post.URL absoluteString] forKey:@"permalink"]; + [postParams setValueIfNotNil:post.excerpt forKey:@"mt_excerpt"]; + [postParams setValueIfNotNil:post.slug forKey:@"wp_slug"]; + [postParams setValueIfNotNil:post.authorID forKey:@"wp_author_id"]; + + // To remove a featured image, you have to send an empty string to the API + if (post.postThumbnailID == nil) { + // Including an empty string for wp_post_thumbnail generates + // an "Invalid attachment ID" error in the call to wp.newPage + if (existingPost) { + postParams[@"wp_post_thumbnail"] = @""; + } + } else if (!existingPost || post.isFeaturedImageChanged) { + // Do not add this param to existing posts when the featured image has not changed. + // Doing so results in a XML-RPC fault: Invalid attachment ID. + postParams[@"wp_post_thumbnail"] = post.postThumbnailID; + } + + [postParams setValueIfNotNil:post.format forKey:@"wp_post_format"]; + [postParams setValueIfNotNil:[post.tags componentsJoinedByString:@","] forKey:@"mt_keywords"]; + + if (existingPost && post.date == nil) { + // Change the date of an already published post to the current date/time. (publish immediately) + // Pass the current date so the post is updated correctly + postParams[@"date_created_gmt"] = [NSDate date]; + } + if (post.categories) { + NSArray *categoryNames = [post.categories wpkit_map:^id(RemotePostCategory *category) { + return category.name; + }]; + + postParams[@"categories"] = categoryNames; + } + + if ([post.metadata count] > 0) { + postParams[@"custom_fields"] = post.metadata; + } + + postParams[@"wp_page_parent_id"] = post.parentID ? post.parentID.stringValue : @"0"; + + // Scheduled posts need to sync with a status of 'publish'. + // Passing a status of 'future' will set the post status to 'draft' + // This is an apparent inconsistency in the XML-RPC API as 'future' should + // be a valid status. + // https://codex.wordpress.org/Post_Status_Transitions + if (post.status == nil || [post.status isEqualToString:PostStatusScheduled]) { + post.status = PostStatusPublish; + } + + // At least as of 5.2.2, Private and/or Password Protected posts can't be stickied. + // However, the code used on the backend doesn't check the value of the `sticky` field, + // instead doing a simple `! empty( $post_data['sticky'] )` check. + // + // This means we have to omit this field entirely for those posts from the payload we're sending + // to the XML-RPC sevices. + // + // https://github.com/WordPress/WordPress/blob/master/wp-includes/class-wp-xmlrpc-server.php + // + BOOL shouldIncludeStickyField = ![post.status isEqualToString:PostStatusPrivate] && post.password == nil; + + if (post.isStickyPost != nil && shouldIncludeStickyField) { + postParams[@"sticky"] = post.isStickyPost.boolValue ? @"true" : @"false"; + } + + if ([post.type isEqualToString:@"page"]) { + [postParams setObject:post.status forKey:@"page_status"]; + } + [postParams setObject:post.status forKey:@"post_status"]; + + return [NSDictionary dictionaryWithDictionary:postParams]; +} + +@end diff --git a/Modules/Sources/WordPressKitObjC/ReaderPostServiceRemote.m b/Modules/Sources/WordPressKitObjC/ReaderPostServiceRemote.m new file mode 100644 index 000000000000..745dc42e36aa --- /dev/null +++ b/Modules/Sources/WordPressKitObjC/ReaderPostServiceRemote.m @@ -0,0 +1,275 @@ +#import "ReaderPostServiceRemote.h" +#import "RemoteReaderPost.h" +#import "RemoteSourcePostAttribution.h" +#import "ReaderTopicServiceRemote.h" +#import "WPKitDateUtils.h" +#import "NSString+Helpers.h" +#import "WPMapFilterReduce.h" + +@import NSObject_SafeExpectations; + +NSString * const PostRESTKeyPosts = @"posts"; + +// Param keys +NSString * const ParamsKeyAlgorithm = @"algorithm"; +NSString * const ParamKeyBefore = @"before"; +NSString * const ParamKeyMeta = @"meta"; +NSString * const ParamKeyNumber = @"number"; +NSString * const ParamKeyOffset = @"offset"; +NSString * const ParamKeyOrder = @"order"; +NSString * const ParamKeyDescending = @"DESC"; +NSString * const ParamKeyMetaValue = @"site,feed"; + +@implementation ReaderPostServiceRemote + +- (void)fetchPostsFromEndpoint:(NSURL *)endpoint + algorithm:(NSString *)algorithm + count:(NSUInteger)count + before:(NSDate *)date + success:(void (^)(NSArray *posts, NSString *algorithm))success + failure:(void (^)(NSError *error))failure +{ + NSNumber *numberToFetch = @(count); + NSMutableDictionary *params = [@{ + ParamKeyNumber:numberToFetch, + ParamKeyBefore: [WPKitDateUtils isoStringFromDate:date], + ParamKeyOrder: ParamKeyDescending, + ParamKeyMeta: ParamKeyMetaValue + } mutableCopy]; + if (algorithm) { + params[ParamsKeyAlgorithm] = algorithm; + } + + [self fetchPostsFromEndpoint:endpoint withParameters:params success:success failure:failure]; +} + +- (void)fetchPostsFromEndpoint:(NSURL *)endpoint + algorithm:(NSString *)algorithm + count:(NSUInteger)count + offset:(NSUInteger)offset + success:(void (^)(NSArray *posts, NSString *algorithm))success + failure:(void (^)(NSError *))failure +{ + NSMutableDictionary *params = [@{ + ParamKeyNumber:@(count), + ParamKeyOffset: @(offset), + ParamKeyOrder: ParamKeyDescending, + ParamKeyMeta: ParamKeyMetaValue + } mutableCopy]; + if (algorithm) { + params[ParamsKeyAlgorithm] = algorithm; + } + [self fetchPostsFromEndpoint:endpoint withParameters:params success:success failure:failure]; +} + +- (void)fetchPost:(NSUInteger)postID + fromSite:(NSUInteger)siteID + isFeed:(BOOL)isFeed + success:(void (^)(RemoteReaderPost *post))success + failure:(void (^)(NSError *error))failure { + + NSString *feedType = (isFeed) ? @"feed" : @"sites"; + NSString *path = [NSString stringWithFormat:@"read/%@/%lu/posts/%lu/?meta=site", feedType, (unsigned long)siteID, (unsigned long)postID]; + + NSString *requestUrl = [self pathForEndpoint:path + withVersion:WordPressComRESTAPIVersion_1_2]; + + [self.wordPressComRESTAPI get:requestUrl + parameters:nil + success:^(id responseObject, NSHTTPURLResponse *httpResponse) { + if (!success) { + return; + } + + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_LOW, 0), ^{ + // Do all of this work on a background thread, then call success on the main thread. + // Do this to avoid any chance of blocking the UI while parsing. + RemoteReaderPost *post = [[RemoteReaderPost alloc] initWithDictionary: (NSDictionary *)responseObject]; + dispatch_async(dispatch_get_main_queue(), ^{ + success(post); + }); + }); + + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + +/** + Fetches a specific post from the specified URL + + @param postURL The URL of the post to fetch + @param success block called on a successful fetch. + @param failure block called if there is any error. `error` can be any underlying network error. + */ +- (void)fetchPostAtURL:(NSURL *)postURL + success:(void (^)(RemoteReaderPost *post))success + failure:(void (^)(NSError *error))failure +{ + NSString *path = [self apiPathForPostAtURL:postURL]; + + if (!path) { + failure(nil); + return; + } + + [self.wordPressComRESTAPI get:path + parameters:nil + success:^(id responseObject, NSHTTPURLResponse *httpResponse) { + if (!success) { + return; + } + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_LOW, 0), ^{ + // Do all of this work on a background thread, then call success on the main thread. + // Do this to avoid any chance of blocking the UI while parsing. + RemoteReaderPost *post = [[RemoteReaderPost alloc] initWithDictionary:(NSDictionary *)responseObject]; + dispatch_async(dispatch_get_main_queue(), ^{ + success(post); + }); + }); + + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + +- (void)likePost:(NSUInteger)postID + forSite:(NSUInteger)siteID + success:(void (^)(void))success + failure:(void (^)(NSError *error))failure +{ + NSString *path = [NSString stringWithFormat:@"sites/%lu/posts/%lu/likes/new", (unsigned long)siteID, (unsigned long)postID]; + NSString *requestUrl = [self pathForEndpoint:path + withVersion:WordPressComRESTAPIVersion_1_1]; + + [self.wordPressComRESTAPI post:requestUrl parameters:nil success:^(id responseObject, NSHTTPURLResponse *httpResponse) { + if (success) { + success(); + } + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + +- (void)unlikePost:(NSUInteger)postID + forSite:(NSUInteger)siteID + success:(void (^)(void))success + failure:(void (^)(NSError *error))failure +{ + NSString *path = [NSString stringWithFormat:@"sites/%lu/posts/%lu/likes/mine/delete", (unsigned long)siteID, (unsigned long)postID]; + NSString *requestUrl = [self pathForEndpoint:path + withVersion:WordPressComRESTAPIVersion_1_1]; + + [self.wordPressComRESTAPI post:requestUrl parameters:nil success:^(id responseObject, NSHTTPURLResponse *httpResponse) { + if (success) { + success(); + } + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + +- (NSString *)endpointUrlForSearchPhrase:(NSString *)phrase +{ + NSAssert([phrase length] > 0, @"A search phrase is required."); + + NSString *endpoint = [NSString stringWithFormat:@"read/search?q=%@", [phrase wpkit_stringByUrlEncoding]]; + NSString *absolutePath = [self pathForEndpoint:endpoint withVersion:WordPressComRESTAPIVersion_1_2]; + NSURL *url = [NSURL URLWithString:absolutePath relativeToURL:self.wordPressComRESTAPI.baseURL]; + return [url absoluteString]; +} + + +#pragma mark - Private Methods + +/** + Fetches the posts from the specified remote endpoint + + @param params A dictionary of parameters supported by the endpoint. Params are converted to the request's query string. + @param success block called on a successful fetch. + @param failure block called if there is any error. `error` can be any underlying network error. + */ +- (void)fetchPostsFromEndpoint:(NSURL *)endpoint + withParameters:(NSDictionary *)params + success:(void (^)(NSArray *posts, NSString *algorithm))success + failure:(void (^)(NSError *))failure +{ + NSString *path = [endpoint absoluteString]; + [self.wordPressComRESTAPI get:path + parameters:params + success:^(id responseObject, NSHTTPURLResponse *httpResponse) { + if (!success) { + return; + } + + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_LOW, 0), ^{ + // NOTE: Do all of this work on a background thread, then call success on the main thread. + // Do this to avoid any chance of blocking the UI while parsing. + + // NOTE: If an offset param was specified sortRank will be derived + // from the offset + order of the results, ONLY if a `before` param + // was not specified. If a `before` param exists we favor sorting by date. + BOOL rankByOffset = [params objectForKey:ParamKeyOffset] != nil && [params objectForKey:ParamKeyBefore] == nil; + __block CGFloat offset = [[params numberForKey:ParamKeyOffset] floatValue]; + NSString *algorithm = [responseObject stringForKey:ParamsKeyAlgorithm]; + NSArray *jsonPosts = [responseObject arrayForKey:PostRESTKeyPosts]; + NSArray *posts = [jsonPosts wpkit_map:^id(NSDictionary *jsonPost) { + if (rankByOffset) { + RemoteReaderPost *post = [self formatPostDictionary:jsonPost offset:offset]; + offset++; + return post; + } + return [[RemoteReaderPost alloc] initWithDictionary:jsonPost]; + }]; + + // Now call success on the main thread. + dispatch_async(dispatch_get_main_queue(), ^{ + success(posts, algorithm); + }); + }); + + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + +- (RemoteReaderPost *)formatPostDictionary:(NSDictionary *)dict offset:(CGFloat)offset +{ + RemoteReaderPost *post = [[RemoteReaderPost alloc] initWithDictionary:dict]; + // It's assumed that sortRank values are in descending order. Since + // offsets are ascending, we store its negative to ensure we get a proper sort order. + CGFloat adjustedOffset = -offset; + post.sortRank = @(adjustedOffset); + return post; +} + +- (nullable NSString *)apiPathForPostAtURL:(NSURL *)url +{ + NSURLComponents *components = [NSURLComponents componentsWithURL:url resolvingAgainstBaseURL:NO]; + + NSString *hostname = components.host; + NSArray *pathComponents = [[components.path pathComponents] filteredArrayUsingPredicate:[NSPredicate predicateWithFormat:@"SELF != '/'"] ]; + NSString *slug = [components.path lastPathComponent]; + + // We expect 4 path components for a post – year, month, day, slug, plus a '/' on either end + if (hostname == nil || pathComponents.count != 4 || slug == nil) { + return nil; + } + + NSString *path = [NSString stringWithFormat:@"sites/%@/posts/slug:%@?meta=site,likes", hostname, slug]; + + return [self pathForEndpoint:path + withVersion:WordPressComRESTAPIVersion_1_1]; +} + +@end diff --git a/Modules/Sources/WordPressKitObjC/ReaderSiteServiceRemote.m b/Modules/Sources/WordPressKitObjC/ReaderSiteServiceRemote.m new file mode 100644 index 000000000000..0ddd4305bbf2 --- /dev/null +++ b/Modules/Sources/WordPressKitObjC/ReaderSiteServiceRemote.m @@ -0,0 +1,360 @@ +#import "ReaderSiteServiceRemote.h" +#import "WPKitLogging.h" + +@import WordPressKitModels; +@import NSObject_SafeExpectations; + +static NSString* const ReaderSiteServiceRemoteURLKey = @"url"; +static NSString* const ReaderSiteServiceRemoteSourceKey = @"source"; +static NSString* const ReaderSiteServiceRemoteSourceValue = @"ios"; + +NSString * const ReaderSiteServiceRemoteErrorDomain = @"ReaderSiteServiceRemoteErrorDomain"; + +@implementation ReaderSiteServiceRemote + +- (void)fetchFollowedSitesWithSuccess:(void(^)(NSArray *sites))success failure:(void(^)(NSError *error))failure +{ + NSString *path = @"read/following/mine?meta=site,feed"; + NSString *requestUrl = [self pathForEndpoint:path + withVersion:WordPressComRESTAPIVersion_1_1]; + + [self.wordPressComRESTAPI get:requestUrl parameters:nil success:^(id responseObject, NSHTTPURLResponse *httpResponse) { + if (!success) { + return; + } + NSDictionary *response = (NSDictionary *)responseObject; + NSArray *subscriptions = [response arrayForKey:@"subscriptions"]; + NSMutableArray *sites = [NSMutableArray array]; + for (NSDictionary *dict in subscriptions) { + RemoteReaderSite *site = [self normalizeSiteDictionary:dict]; + site.isSubscribed = YES; + [sites addObject:site]; + } + success(sites); + + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + +- (void)followSiteWithID:(NSUInteger)siteID success:(void (^)(void))success failure:(void(^)(NSError *error))failure +{ + NSString *path = [NSString stringWithFormat:@"sites/%lu/follows/new?%@=%@", (unsigned long)siteID, ReaderSiteServiceRemoteSourceKey, ReaderSiteServiceRemoteSourceValue]; + NSString *requestUrl = [self pathForEndpoint:path + withVersion:WordPressComRESTAPIVersion_1_1]; + + [self.wordPressComRESTAPI post:requestUrl parameters:nil success:^(id responseObject, NSHTTPURLResponse *httpResponse) { + if (success) { + success(); + } + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + +- (void)unfollowSiteWithID:(NSUInteger)siteID success:(void (^)(void))success failure:(void(^)(NSError *error))failure +{ + NSString *path = [NSString stringWithFormat:@"sites/%lu/follows/mine/delete", (unsigned long)siteID]; + NSString *requestUrl = [self pathForEndpoint:path + withVersion:WordPressComRESTAPIVersion_1_1]; + + [self.wordPressComRESTAPI post:requestUrl parameters:nil success:^(id responseObject, NSHTTPURLResponse *httpResponse) { + if (success) { + success(); + } + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + +- (void)followSiteAtURL:(NSString *)siteURL success:(void (^)(void))success failure:(void(^)(NSError *error))failure +{ + NSString *path = @"read/following/mine/new"; + NSString *requestUrl = [self pathForEndpoint:path + withVersion:WordPressComRESTAPIVersion_1_1]; + + NSDictionary *params = @{ReaderSiteServiceRemoteURLKey: siteURL, + ReaderSiteServiceRemoteSourceKey: ReaderSiteServiceRemoteSourceValue}; + [self.wordPressComRESTAPI post:requestUrl parameters:params success:^(id responseObject, NSHTTPURLResponse *httpResponse) { + NSDictionary *dict = (NSDictionary *)responseObject; + BOOL subscribed = [[dict numberForKey:@"subscribed"] boolValue]; + if (!subscribed) { + if (failure) { + WPKitLogError(@"Error following site at url: %@", siteURL); + NSError *error = [self errorForUnsuccessfulFollowSite]; + failure(error); + } + return; + } + if (success) { + success(); + } + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + +- (void)unfollowSiteAtURL:(NSString *)siteURL success:(void (^)(void))success failure:(void(^)(NSError *error))failure +{ + NSString *path = @"read/following/mine/delete"; + NSString *requestUrl = [self pathForEndpoint:path + withVersion:WordPressComRESTAPIVersion_1_1]; + + NSDictionary *params = @{ReaderSiteServiceRemoteURLKey: siteURL}; + + [self.wordPressComRESTAPI post:requestUrl parameters:params success:^(id responseObject, NSHTTPURLResponse *httpResponse) { + NSDictionary *dict = (NSDictionary *)responseObject; + BOOL subscribed = [[dict numberForKey:@"subscribed"] boolValue]; + if (subscribed) { + if (failure) { + WPKitLogError(@"Error unfollowing site at url: %@", siteURL); + NSError *error = [self errorForUnsuccessfulFollowSite]; + failure(error); + } + return; + } + if (success) { + success(); + } + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + +- (void)findSiteIDForURL:(NSURL *)siteURL success:(void (^)(NSUInteger siteID))success failure:(void(^)(NSError *error))failure +{ + NSString *host = [siteURL host]; + if (!host) { + // error; + if (failure) { + NSError *error = [self errorForInvalidHost]; + failure(error); + } + return; + } + + // Define success block + void (^successBlock)(id responseObject, NSHTTPURLResponse *response) = ^void(id responseObject, NSHTTPURLResponse *httpResponse) { + if (!success) { + return; + } + NSDictionary *dict = (NSDictionary *)responseObject; + NSUInteger siteID = [[dict numberForKey:@"ID"] integerValue]; + success(siteID); + }; + + // Define failure block + void (^failureBlock)(NSError *error, NSHTTPURLResponse *httpResponse) = ^void(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }; + + NSString *path = [NSString stringWithFormat:@"sites/%@", host]; + NSString *requestUrl = [self pathForEndpoint:path + withVersion:WordPressComRESTAPIVersion_1_1]; + + [self.wordPressComRESTAPI get:requestUrl parameters:nil success:successBlock failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + NSString *newHost; + if ([host hasPrefix:@"www."]) { + // If the provided host includes a www. prefix, try again without it. + newHost = [host substringFromIndex:4]; + + } else { + // If the provided host includes a www. prefix, try again without it. + newHost = [NSString stringWithFormat:@"www.%@", host]; + + } + NSString *newPath = [NSString stringWithFormat:@"sites/%@", newHost]; + NSString *newPathRequestUrl = [self pathForEndpoint:newPath + withVersion:WordPressComRESTAPIVersion_1_1]; + + [self.wordPressComRESTAPI get:newPathRequestUrl parameters:nil success:successBlock failure:failureBlock]; + }]; +} + +- (void)checkSiteExistsAtURL:(NSURL *)siteURL success:(void (^)(void))success failure:(void(^)(NSError *error))failure +{ + NSURLSession *session = NSURLSession.sharedSession; + NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:siteURL cachePolicy:NSURLRequestReloadIgnoringLocalAndRemoteCacheData timeoutInterval:5]; + request.HTTPMethod = @"HEAD"; + NSURLSessionTask *task = [session dataTaskWithRequest:request completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) { + if (error) { + if (failure) { + failure(error); + } + return; + } + if([response isKindOfClass:[NSHTTPURLResponse class]]) { + NSHTTPURLResponse *httpResponse = (NSHTTPURLResponse *)response; + NSIndexSet *acceptableStatus = [NSIndexSet indexSetWithIndexesInRange:NSMakeRange(200, 99)]; + if (![acceptableStatus containsIndex:httpResponse.statusCode]) { + if (failure) { + NSError *statusError = [NSError errorWithDomain:(NSString *)kCFErrorDomainCFNetwork code:kCFFTPErrorUnexpectedStatusCode userInfo:nil]; + failure(statusError); + } + return; + } + } + if (success) { + success(); + } + }]; + [task resume]; +} + +- (void)checkSubscribedToSiteByID:(NSUInteger)siteID success:(void (^)(BOOL follows))success failure:(void(^)(NSError *error))failure +{ + NSString *path = [NSString stringWithFormat:@"sites/%lu/follows/mine", (unsigned long)siteID]; + NSString *requestUrl = [self pathForEndpoint:path + withVersion:WordPressComRESTAPIVersion_1_1]; + + [self.wordPressComRESTAPI get:requestUrl parameters:nil success:^(id responseObject, NSHTTPURLResponse *httpResponse) { + if (!success) { + return; + } + NSDictionary *dict = (NSDictionary *)responseObject; + BOOL follows = [[dict numberForKey:@"is_following"] boolValue]; + success(follows); + + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + +- (void)checkSubscribedToFeedByURL:(NSURL *)siteURL success:(void (^)(BOOL follows))success failure:(void(^)(NSError *error))failure +{ + NSString *path = @"read/following/mine"; + NSString *requestUrl = [self pathForEndpoint:path + withVersion:WordPressComRESTAPIVersion_1_1]; + + [self.wordPressComRESTAPI get:requestUrl parameters:nil success:^(id responseObject, NSHTTPURLResponse *httpResponse) { + if (!success) { + return; + } + + BOOL follows = NO; + NSString *responseString = [[responseObject description] stringByRemovingPercentEncoding]; + if ([responseString rangeOfString:[siteURL absoluteString]].location != NSNotFound) { + follows = YES; + } + success(follows); + + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + +- (void)flagSiteWithID:(NSUInteger)siteID asBlocked:(BOOL)blocked success:(void(^)(void))success failure:(void(^)(NSError *error))failure +{ + NSString *path; + if (blocked) { + path = [NSString stringWithFormat:@"me/block/sites/%lu/new", (unsigned long)siteID]; + } else { + path = [NSString stringWithFormat:@"me/block/sites/%lu/delete", (unsigned long)siteID]; + } + + NSString *requestUrl = [self pathForEndpoint:path + withVersion:WordPressComRESTAPIVersion_1_1]; + + [self.wordPressComRESTAPI post:requestUrl parameters:nil success:^(id responseObject, NSHTTPURLResponse *httpResponse) { + NSDictionary *dict = (NSDictionary *)responseObject; + if (![[dict numberForKey:@"success"] boolValue]) { + if (blocked) { + failure([self errorForUnsuccessfulBlockSite]); + } else { + failure([self errorForUnsuccessfulUnblockSite]); + } + return; + } + + if (success) { + success(); + } + + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + + +#pragma mark - Private Methods + +- (RemoteReaderSite *)normalizeSiteDictionary:(NSDictionary *)dict +{ + NSDictionary *meta = [dict dictionaryForKeyPath:@"meta.data.site"]; + if (!meta) { + meta = [dict dictionaryForKeyPath:@"meta.data.feed"]; + } + + RemoteReaderSite *site = [[RemoteReaderSite alloc] init]; + site.recordID = [dict numberForKey:@"ID"]; + site.path = [dict stringForKey:@"URL"]; // Retrieve from the parent dictionary due to a bug in the REST API that returns NULL in the feed dictionary in some cases. + site.siteID = [meta numberForKey:@"ID"]; + site.feedID = [meta numberForKey:@"feed_ID"]; + site.name = [meta stringForKey:@"name"]; + if ([site.name length] == 0) { + site.name = site.path; + } + site.icon = [meta stringForKeyPath:@"icon.img"]; + + return site; +} + +- (NSError *)errorForInvalidHost +{ + NSString *description = NSLocalizedString(@"The URL is missing a valid host.", @"Error message describing a problem with a URL."); + NSDictionary *userInfo = @{NSLocalizedDescriptionKey:description}; + NSError *error = [[NSError alloc] initWithDomain:ReaderSiteServiceRemoteErrorDomain code:ReaderSiteServiceRemoteInvalidHost userInfo:userInfo]; + return error; +} + +- (NSError *)errorForUnsuccessfulFollowSite +{ + NSString *description = NSLocalizedString(@"Could not follow the site at the address specified.", @"Error message informing the user that there was a problem subscribing to a site or feed."); + NSDictionary *userInfo = @{NSLocalizedDescriptionKey:description}; + NSError *error = [[NSError alloc] initWithDomain:ReaderSiteServiceRemoteErrorDomain code:ReaderSiteServiceRemoteUnsuccessfulFollowSite userInfo:userInfo]; + return error; +} + +- (NSError *)errorForUnsuccessfulUnfollowSite +{ + NSString *description = NSLocalizedString(@"Could not unfollow the site at the address specified.", @"Error message informing the user that there was a problem unsubscribing to a site or feed."); + NSDictionary *userInfo = @{NSLocalizedDescriptionKey:description}; + NSError *error = [[NSError alloc] initWithDomain:ReaderSiteServiceRemoteErrorDomain code:ReaderSiteServiceRemoteUnsuccessfulUnfollowSite userInfo:userInfo]; + return error; +} + +- (NSError *)errorForUnsuccessfulBlockSite +{ + NSString *description = NSLocalizedString(@"There was a problem blocking posts from the specified site.", @"Error message informing the user that there was a problem blocking posts from a site from their reader."); + NSDictionary *userInfo = @{NSLocalizedDescriptionKey:description}; + NSError *error = [[NSError alloc] initWithDomain:ReaderSiteServiceRemoteErrorDomain code:ReaderSiteSErviceRemoteUnsuccessfulBlockSite userInfo:userInfo]; + return error; +} + +- (NSError *)errorForUnsuccessfulUnblockSite +{ + NSString *description = NSLocalizedString(@"There was a problem removing the block for specified site.", @"Error message informing the user that there was a problem clearing the block on site preventing its posts from displaying in the reader."); + NSDictionary *userInfo = @{NSLocalizedDescriptionKey:description}; + NSError *error = [[NSError alloc] initWithDomain:ReaderSiteServiceRemoteErrorDomain code:ReaderSiteSErviceRemoteUnsuccessfulBlockSite userInfo:userInfo]; + return error; +} + +@end diff --git a/Modules/Sources/WordPressKitObjC/ReaderTopicServiceRemote.m b/Modules/Sources/WordPressKitObjC/ReaderTopicServiceRemote.m new file mode 100644 index 000000000000..a350db72eb03 --- /dev/null +++ b/Modules/Sources/WordPressKitObjC/ReaderTopicServiceRemote.m @@ -0,0 +1,331 @@ +#import "ReaderTopicServiceRemote.h" +#import "WPMapFilterReduce.h" + +@import WordPressKitModels; +@import NSObject_SafeExpectations; + +static NSString * const TopicMenuSectionDefaultKey = @"default"; +static NSString * const TopicMenuSectionSubscribedKey = @"subscribed"; +static NSString * const TopicMenuSectionRecommendedKey = @"recommended"; +static NSString * const TopicRemovedTagKey = @"removed_tag"; +static NSString * const TopicAddedTagKey = @"added_tag"; +static NSString * const TopicDictionaryTagKey = @"tag"; +static NSString * const TopicNotFoundMarker = @"-notfound-"; + +@implementation ReaderTopicServiceRemote + +- (void)fetchReaderMenuWithSuccess:(void (^)(NSArray *topics))success failure:(void (^)(NSError *error))failure +{ + NSString *path = @"read/menu"; + NSString *requestUrl = [self pathForEndpoint:path + withVersion:WordPressComRESTAPIVersion_1_3]; + + [self.wordPressComRESTAPI get:requestUrl parameters:nil success:^(NSDictionary *response, NSHTTPURLResponse *httpResponse) { + if (!success) { + return; + } + + // Normalize and flatten the results. + // A topic can appear in both recommended and subscribed dictionaries, + // so filter appropriately. + NSMutableArray *topics = [NSMutableArray array]; + + NSDictionary *defaults = [response dictionaryForKey:TopicMenuSectionDefaultKey]; + NSMutableDictionary *subscribed = [[response dictionaryForKey:TopicMenuSectionSubscribedKey] mutableCopy]; + NSMutableDictionary *recommended = [[response dictionaryForKey:TopicMenuSectionRecommendedKey] mutableCopy]; + NSArray *subscribedAndRecommended; + + NSMutableSet *subscribedSet = [NSMutableSet setWithArray:[subscribed allKeys]]; + NSSet *recommendedSet = [NSSet setWithArray:[recommended allKeys]]; + [subscribedSet intersectSet:recommendedSet]; + NSArray *sharedkeys = [subscribedSet allObjects]; + + if (sharedkeys) { + subscribedAndRecommended = [subscribed objectsForKeys:sharedkeys notFoundMarker:TopicNotFoundMarker]; + [subscribed removeObjectsForKeys:sharedkeys]; + [recommended removeObjectsForKeys:sharedkeys]; + } + + [topics addObjectsFromArray:[self normalizeMenuTopicsList:[defaults allValues] subscribed:NO recommended:NO]]; + [topics addObjectsFromArray:[self normalizeMenuTopicsList:[subscribed allValues] subscribed:YES recommended:NO]]; + [topics addObjectsFromArray:[self normalizeMenuTopicsList:[recommended allValues] subscribed:NO recommended:YES]]; + [topics addObjectsFromArray:[self normalizeMenuTopicsList:subscribedAndRecommended subscribed:YES recommended:YES]]; + + success(topics); + + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + +- (void)fetchFollowedSitesWithSuccess:(void(^)(NSArray *sites))success failure:(void(^)(NSError *error))failure +{ + void (^wrappedSuccess)(NSNumber *, NSArray *) = ^(NSNumber *totalSites, NSArray *sites) { + if (success) { + success(sites); + } + }; + + [self fetchFollowedSitesForPage:0 number:0 success:wrappedSuccess failure:failure]; +} + +- (void)fetchFollowedSitesForPage:(NSUInteger)page + number:(NSUInteger)number + success:(void(^)(NSNumber *totalSites, NSArray *sites))success + failure:(void(^)(NSError *error))failure +{ + NSString *path = [self pathForFollowedSitesWithPage:page number:number]; + NSString *requestUrl = [self pathForEndpoint:path + withVersion:WordPressComRESTAPIVersion_1_2]; + + [self.wordPressComRESTAPI get:requestUrl parameters:nil success:^(id responseObject, NSHTTPURLResponse *httpResponse) { + if (!success) { + return; + } + NSDictionary *response = (NSDictionary *)responseObject; + NSNumber *totalSites = [response numberForKey:@"total_subscriptions"]; + NSArray *subscriptions = [response arrayForKey:@"subscriptions"]; + NSMutableArray *sites = [NSMutableArray array]; + for (NSDictionary *dict in subscriptions) { + RemoteReaderSiteInfo *siteInfo = [self siteInfoFromFollowedSiteDictionary:dict]; + [sites addObject:siteInfo]; + } + success(totalSites, sites); + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + +- (void)unfollowTopicWithSlug:(NSString *)slug + withSuccess:(void (^)(NSNumber *topicID))success + failure:(void (^)(NSError *error))failure +{ + NSString *path = [NSString stringWithFormat:@"read/tags/%@/mine/delete", slug]; + NSString *requestUrl = [self pathForEndpoint:path + withVersion:WordPressComRESTAPIVersion_1_1]; + + [self.wordPressComRESTAPI post:requestUrl parameters:nil success:^(NSDictionary *responseObject, NSHTTPURLResponse *httpResponse) { + if (!success) { + return; + } + NSNumber *unfollowedTag = [responseObject numberForKey:TopicRemovedTagKey]; + success(unfollowedTag); + + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + +- (void)followTopicNamed:(NSString *)topicName + withSuccess:(void (^)(NSNumber *topicID))success + failure:(void (^)(NSError *error))failure +{ + NSString *slug = [self slugForTopicName:topicName]; + [self followTopicWithSlug:slug withSuccess:success failure:failure]; +} + +- (void)followTopicWithSlug:(NSString *)slug + withSuccess:(void (^)(NSNumber *topicID))success + failure:(void (^)(NSError *error))failure +{ + NSString *path = [NSString stringWithFormat:@"read/tags/%@/mine/new", slug]; + path = [path stringByAddingPercentEncodingWithAllowedCharacters:[NSCharacterSet URLPathAllowedCharacterSet]]; + NSString *requestUrl = [self pathForEndpoint:path + withVersion:WordPressComRESTAPIVersion_1_1]; + + [self.wordPressComRESTAPI post:requestUrl parameters:nil success:^(id responseObject, NSHTTPURLResponse *httpResponse) { + if (!success) { + return; + } + NSNumber *followedTag = [responseObject numberForKey:TopicAddedTagKey]; + success(followedTag); + + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + +- (void)fetchTagInfoForTagWithSlug:(NSString *)slug + success:(void (^)(RemoteReaderTopic *remoteTopic))success + failure:(void (^)(NSError *error))failure +{ + NSString *path = [NSString stringWithFormat:@"read/tags/%@", slug]; + NSString *requestUrl = [self pathForEndpoint:path + withVersion:WordPressComRESTAPIVersion_1_2]; + + [self.wordPressComRESTAPI get:requestUrl parameters:nil success:^(id responseObject, NSHTTPURLResponse *httpResponse) { + if (!success) { + return; + } + + NSDictionary *response = (NSDictionary *)responseObject; + NSDictionary *topicDict = [response dictionaryForKey:TopicDictionaryTagKey]; + RemoteReaderTopic *remoteTopic = [self normalizeMenuTopicDictionary:topicDict subscribed:NO recommended:NO]; + remoteTopic.isMenuItem = NO; + success(remoteTopic); + + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + +- (void)fetchSiteInfoForSiteWithID:(NSNumber *)siteID + isFeed:(BOOL)isFeed + success:(void (^)(RemoteReaderSiteInfo *siteInfo))success + failure:(void (^)(NSError *error))failure +{ + NSString *requestUrl; + if (isFeed) { + NSString *path = [NSString stringWithFormat:@"read/feed/%@", siteID]; + requestUrl = [self pathForEndpoint:path + withVersion:WordPressComRESTAPIVersion_1_1]; + } else { + NSString *path = [NSString stringWithFormat:@"read/sites/%@", siteID]; + requestUrl = [self pathForEndpoint:path + withVersion:WordPressComRESTAPIVersion_1_2]; + } + + [self.wordPressComRESTAPI get:requestUrl parameters:nil success:^(id responseObject, NSHTTPURLResponse *httpResponse) { + if (!success) { + return; + } + + + NSDictionary *response = (NSDictionary *)responseObject; + RemoteReaderSiteInfo *siteInfo = [RemoteReaderSiteInfo siteInfoForSiteResponse:response + isFeed:isFeed]; + + siteInfo.postsEndpoint = [self endpointUrlForPath:siteInfo.endpointPath]; + + success(siteInfo); + + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + +- (RemoteReaderSiteInfo *)siteInfoFromFollowedSiteDictionary:(NSDictionary *)dict +{ + NSDictionary *meta = [dict dictionaryForKeyPath:@"meta.data.site"]; + RemoteReaderSiteInfo *siteInfo; + + if (meta) { + siteInfo = [RemoteReaderSiteInfo siteInfoForSiteResponse:meta isFeed:NO]; + } else { + meta = [dict dictionaryForKeyPath:@"meta.data.feed"]; + siteInfo = [RemoteReaderSiteInfo siteInfoForSiteResponse:meta isFeed:YES]; + } + + siteInfo.postsEndpoint = [self endpointUrlForPath:siteInfo.endpointPath]; + + return siteInfo; +} + +- (NSString *)endpointUrlForPath:(NSString *)endpoint +{ + NSString *absolutePath = [self pathForEndpoint:endpoint withVersion:WordPressComRESTAPIVersion_1_2]; + NSURL *url = [NSURL URLWithString:absolutePath relativeToURL:self.wordPressComRESTAPI.baseURL]; + return [url absoluteString]; +} + + +#pragma mark - Private Methods + +/** + Formats the specified string for use as part of the URL path for the tags endpoints + in the REST API. Spaces and periods are converted to dashes, ampersands and hashes are + removed. + See https://github.com/WordPress/WordPress/blob/master/wp-includes/formatting.php#L1258 + + @param topicName The string to be formatted. + @return The formatted string. + */ +- (NSString *)slugForTopicName:(NSString *)topicName +{ + if (!topicName || [topicName length] == 0) { + return @""; + } + + static NSRegularExpression *regexHtmlEntities; + static NSRegularExpression *regexPeriodsWhitespace; + static NSRegularExpression *regexNonAlphaNumNonDash; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + NSError *error; + regexHtmlEntities = [NSRegularExpression regularExpressionWithPattern:@"&[^\\s]*;" options:NSRegularExpressionCaseInsensitive error:&error]; + regexPeriodsWhitespace = [NSRegularExpression regularExpressionWithPattern:@"[\\.\\s]+" options:NSRegularExpressionCaseInsensitive error:&error]; + regexNonAlphaNumNonDash = [NSRegularExpression regularExpressionWithPattern:@"[^\\p{L}\\p{Nd}\\-]+" options:NSRegularExpressionCaseInsensitive error:&error]; + }); + + topicName = [[topicName lowercaseString] stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceCharacterSet]]; + + // remove html entities + topicName = [regexHtmlEntities stringByReplacingMatchesInString:topicName + options:NSMatchingReportProgress + range:NSMakeRange(0, [topicName length]) + withTemplate:@""]; + + // replace periods and whitespace with a dash + topicName = [regexPeriodsWhitespace stringByReplacingMatchesInString:topicName + options:NSMatchingReportProgress + range:NSMakeRange(0, [topicName length]) + withTemplate:@"-"]; + + // remove remaining non-alphanum/non-dash chars + topicName = [regexNonAlphaNumNonDash stringByReplacingMatchesInString:topicName + options:NSMatchingReportProgress + range:NSMakeRange(0, [topicName length]) + withTemplate:@""]; + + // reduce double dashes potentially added above + while ([topicName rangeOfString:@"--"].location != NSNotFound) { + topicName = [topicName stringByReplacingOccurrencesOfString:@"--" withString:@"-"]; + } + + topicName = [topicName stringByRemovingPercentEncoding]; + + return topicName; +} + +- (NSArray *)normalizeMenuTopicsList:(NSArray *)rawTopics subscribed:(BOOL)subscribed recommended:(BOOL)recommended +{ + return [[rawTopics wpkit_filter:^BOOL(id obj) { + return [obj isKindOfClass:[NSDictionary class]]; + }] wpkit_map:^id(NSDictionary *topic) { + return [self normalizeMenuTopicDictionary:topic subscribed:subscribed recommended:recommended]; + }]; +} + +- (RemoteReaderTopic *)normalizeMenuTopicDictionary:(NSDictionary *)topicDict subscribed:(BOOL)subscribed recommended:(BOOL)recommended +{ + RemoteReaderTopic *topic = [[RemoteReaderTopic alloc] initWithDictionary:topicDict subscribed:subscribed recommended:recommended]; + topic.isMenuItem = YES; + return topic; +} + +- (NSString *)pathForFollowedSitesWithPage:(NSUInteger)page number:(NSUInteger)number +{ + NSString *path = @"read/following/mine?meta=site,feed"; + if (page > 0) { + path = [path stringByAppendingFormat:@"&page=%lu", (unsigned long)page]; + } + if (number > 0) { + path = [path stringByAppendingFormat:@"&number=%lu", (unsigned long)number]; + } + + return path; +} + +@end diff --git a/Modules/Sources/WordPressKitObjC/RemoteComment.m b/Modules/Sources/WordPressKitObjC/RemoteComment.m new file mode 100644 index 000000000000..7620e8ae1469 --- /dev/null +++ b/Modules/Sources/WordPressKitObjC/RemoteComment.m @@ -0,0 +1,5 @@ +#import "RemoteComment.h" + +@implementation RemoteComment + +@end diff --git a/Modules/Sources/WordPressKitObjC/RemoteMedia.m b/Modules/Sources/WordPressKitObjC/RemoteMedia.m new file mode 100644 index 000000000000..006bc3241819 --- /dev/null +++ b/Modules/Sources/WordPressKitObjC/RemoteMedia.m @@ -0,0 +1,30 @@ +#import "RemoteMedia.h" +#import + +@implementation RemoteMedia + +- (NSString *)debugDescription { + NSDictionary *properties = [self debugProperties]; + return [NSString stringWithFormat:@"<%@: %p> (%@)", NSStringFromClass([self class]), self, properties]; +} + +- (NSDictionary *)debugProperties { + unsigned int propertyCount; + objc_property_t *properties = class_copyPropertyList([RemoteMedia class], &propertyCount); + NSMutableDictionary *debugProperties = [NSMutableDictionary dictionaryWithCapacity:propertyCount]; + for (int i = 0; i < propertyCount; i++) + { + // Add property name to array + objc_property_t property = properties[i]; + const char *propertyName = property_getName(property); + id value = [self valueForKey:@(propertyName)]; + if (value == nil) { + value = [NSNull null]; + } + [debugProperties setObject:value forKey:@(propertyName)]; + } + free(properties); + return [NSDictionary dictionaryWithDictionary:debugProperties]; +} + +@end \ No newline at end of file diff --git a/Modules/Sources/WordPressKitObjC/RemotePost.m b/Modules/Sources/WordPressKitObjC/RemotePost.m new file mode 100644 index 000000000000..ef7960fef890 --- /dev/null +++ b/Modules/Sources/WordPressKitObjC/RemotePost.m @@ -0,0 +1,50 @@ +#import "RemotePost.h" +#import + +NSString * const PostStatusDraft = @"draft"; +NSString * const PostStatusPending = @"pending"; +NSString * const PostStatusPrivate = @"private"; +NSString * const PostStatusPublish = @"publish"; +NSString * const PostStatusScheduled = @"future"; +NSString * const PostStatusTrash = @"trash"; +NSString * const PostStatusDeleted = @"deleted"; // Returned by wpcom REST API when a post is permanently deleted. + +@implementation RemotePost + +- (id)initWithSiteID:(NSNumber *)siteID status:(NSString *)status title:(NSString *)title content:(NSString *)content +{ + self = [super init]; + if (self) { + _siteID = siteID; + _status = status; + _title = title; + _content = content; + } + return self; +} + +- (NSString *)debugDescription { + NSDictionary *properties = [self debugProperties]; + return [NSString stringWithFormat:@"<%@: %p> (%@)", NSStringFromClass([self class]), self, properties]; +} + +- (NSDictionary *)debugProperties { + unsigned int propertyCount; + objc_property_t *properties = class_copyPropertyList([RemotePost class], &propertyCount); + NSMutableDictionary *debugProperties = [NSMutableDictionary dictionaryWithCapacity:propertyCount]; + for (int i = 0; i < propertyCount; i++) + { + // Add property name to array + objc_property_t property = properties[i]; + const char *propertyName = property_getName(property); + id value = [self valueForKey:@(propertyName)]; + if (value == nil) { + value = [NSNull null]; + } + [debugProperties setObject:value forKey:@(propertyName)]; + } + free(properties); + return [NSDictionary dictionaryWithDictionary:debugProperties]; +} + +@end diff --git a/Modules/Sources/WordPressKitObjC/RemotePostCategory.m b/Modules/Sources/WordPressKitObjC/RemotePostCategory.m new file mode 100644 index 000000000000..235fd1d8fdb8 --- /dev/null +++ b/Modules/Sources/WordPressKitObjC/RemotePostCategory.m @@ -0,0 +1,18 @@ +#import "RemotePostCategory.h" + +@implementation RemotePostCategory + +- (NSString *)debugDescription { + NSDictionary *properties = @{ + @"ID": self.categoryID, + @"name": self.name, + @"parent": self.parentID, + }; + return [NSString stringWithFormat:@"<%@: %p> (%@)", NSStringFromClass([self class]), self, properties]; +} + +- (NSString *)description { + return [NSString stringWithFormat:@"<%@: %p> %@[%@]", NSStringFromClass([self class]), self, self.name, self.categoryID]; +} + +@end diff --git a/Modules/Sources/WordPressKitObjC/RemotePostTag.m b/Modules/Sources/WordPressKitObjC/RemotePostTag.m new file mode 100644 index 000000000000..4f4466ac5d27 --- /dev/null +++ b/Modules/Sources/WordPressKitObjC/RemotePostTag.m @@ -0,0 +1,19 @@ +#import "RemotePostTag.h" + +@implementation RemotePostTag + +- (NSString *)debugDescription +{ + NSDictionary *properties = @{ + @"ID": self.tagID, + @"name": self.name + }; + return [NSString stringWithFormat:@"<%@: %p> (%@)", NSStringFromClass([self class]), self, properties]; +} + +- (NSString *)description +{ + return [NSString stringWithFormat:@"<%@: %p> %@[%@]", NSStringFromClass([self class]), self, self.name, self.tagID]; +} + +@end diff --git a/Modules/Sources/WordPressKitObjC/RemotePostType.m b/Modules/Sources/WordPressKitObjC/RemotePostType.m new file mode 100644 index 000000000000..4d1cc15c8334 --- /dev/null +++ b/Modules/Sources/WordPressKitObjC/RemotePostType.m @@ -0,0 +1,20 @@ +#import "RemotePostType.h" + +@implementation RemotePostType + +- (NSString *)debugDescription +{ + NSDictionary *properties = @{ + @"name": self.name, + @"label": self.label, + @"apiQueryable": self.apiQueryable + }; + return [NSString stringWithFormat:@"<%@: %p> (%@)", NSStringFromClass([self class]), self, properties]; +} + +- (NSString *)description +{ + return [NSString stringWithFormat:@"<%@: %p> %@[%@], apiQueryable=%@", NSStringFromClass([self class]), self, self.name, self.label, self.apiQueryable]; +} + +@end diff --git a/Modules/Sources/WordPressKitObjC/RemoteReaderPost.m b/Modules/Sources/WordPressKitObjC/RemoteReaderPost.m new file mode 100644 index 000000000000..8035c4c6d45c --- /dev/null +++ b/Modules/Sources/WordPressKitObjC/RemoteReaderPost.m @@ -0,0 +1,717 @@ +#import "RemoteReaderPost.h" +#import "RemoteSourcePostAttribution.h" +#import "NSString+Helpers.h" +#import "NSString+XMLExtensions.h" +#import "WPKitDateUtils.h" +#import "WPMapFilterReduce.h" +#import "DisplayableImageHelper.h" + +@import WordPressKitModels; +@import NSObject_SafeExpectations; + +// REST Post dictionary keys +NSString * const PostRESTKeyAttachments = @"attachments"; +NSString * const PostRESTKeyAuthor = @"author"; +NSString * const PostRESTKeyAvatarURL = @"avatar_URL"; +NSString * const PostRESTKeyCommentCount = @"comment_count"; +NSString * const PostRESTKeyCommentsOpen = @"comments_open"; +NSString * const PostRESTKeyContent = @"content"; +NSString * const PostRESTKeyDate = @"date"; +NSString * const PostRESTKeyDateLiked = @"date_liked"; +NSString * const PostRESTKeyDiscoverMetadata = @"discover_metadata"; +NSString * const PostRESTKeyDiscussion = @"discussion"; +NSString * const PostRESTKeyEditorial = @"editorial"; +NSString * const PostRESTKeyEmail = @"email"; +NSString * const PostRESTKeyExcerpt = @"excerpt"; +NSString * const PostRESTKeyFeaturedMedia = @"featured_media"; +NSString * const PostRESTKeyFeaturedImage = @"featured_image"; +NSString * const PostRESTKeyFeedID = @"feed_ID"; +NSString * const PostRESTKeyFeedItemID = @"feed_item_ID"; +NSString * const PostRESTKeyGlobalID = @"global_ID"; +NSString * const PostRESTKeyHighlightTopic = @"highlight_topic"; +NSString * const PostRESTKeyHighlightTopicTitle = @"highlight_topic_title"; +NSString * const PostRESTKeyILike = @"i_like"; +NSString * const PostRESTKeyID = @"ID"; +NSString * const PostRESTKeyIsExternal = @"is_external"; +NSString * const PostRESTKeyIsFollowing = @"is_following"; +NSString * const PostRESTKeyIsJetpack = @"is_jetpack"; +NSString * const PostRESTKeyIsReblogged = @"is_reblogged"; +NSString * const PostRESTKeyIsSeen = @"is_seen"; +NSString * const PostRESTKeyLikeCount = @"like_count"; +NSString * const PostRESTKeyLikesEnabled = @"likes_enabled"; +NSString * const PostRESTKeyName = @"name"; +NSString * const PostRESTKeyNiceName = @"nice_name"; +NSString * const PostRESTKeyPermalink = @"permalink"; +NSString * const PostRESTKeyPostCount = @"post_count"; +NSString * const PostRESTKeyScore = @"score"; +NSString * const PostRESTKeySharingEnabled = @"sharing_enabled"; +NSString * const PostRESTKeySiteID = @"site_ID"; +NSString * const PostRESTKeySiteIsAtomic = @"site_is_atomic"; +NSString * const PostRESTKeySiteIsPrivate = @"site_is_private"; +NSString * const PostRESTKeySiteName = @"site_name"; +NSString * const PostRESTKeySiteURL = @"site_URL"; +NSString * const PostRESTKeySlug = @"slug"; +NSString * const PostRESTKeyStatus = @"status"; +NSString * const PostRESTKeyTitle = @"title"; +NSString * const PostRESTKeyTaggedOn = @"tagged_on"; +NSString * const PostRESTKeyTags = @"tags"; +NSString * const POSTRESTKeyTagDisplayName = @"display_name"; +NSString * const PostRESTKeyURL = @"URL"; +NSString * const PostRESTKeyWordCount = @"word_count"; +NSString * const PostRESTKeyRailcar = @"railcar"; +NSString * const PostRESTKeyOrganizationID = @"meta.data.site.organization_id"; +NSString * const PostRESTKeyCanSubscribeComments = @"can_subscribe_comments"; +NSString * const PostRESTKeyIsSubscribedComments = @"is_subscribed_comments"; +NSString * const POSTRESTKeyReceivesCommentNotifications = @"subscribed_comments_notifications"; + +// Tag dictionary keys +NSString * const TagKeyPrimary = @"primaryTag"; +NSString * const TagKeyPrimarySlug = @"primaryTagSlug"; +NSString * const TagKeySecondary = @"secondaryTag"; +NSString * const TagKeySecondarySlug = @"secondaryTagSlug"; + +// XPost Meta Keys +NSString * const PostRESTKeyMetadata = @"metadata"; +NSString * const CrossPostMetaKey = @"key"; +NSString * const CrossPostMetaValue = @"value"; +NSString * const CrossPostMetaXPostPermalink = @"_xpost_original_permalink"; +NSString * const CrossPostMetaXCommentPermalink = @"xcomment_original_permalink"; +NSString * const CrossPostMetaXPostOrigin = @"xpost_origin"; +NSString * const CrossPostMetaCommentPrefix = @"comment-"; + +static const NSInteger AvgWordsPerMinuteRead = 250; +static const NSInteger MinutesToReadThreshold = 2; +static const NSUInteger ReaderPostTitleLength = 30; + +@implementation RemoteReaderPost + +/** + Sanitizes a post object from the REST API. + + @param dict A dictionary representing a post object from the REST API + @return A `RemoteReaderPost` object + */ +- (instancetype)initWithDictionary:(NSDictionary *)dict; +{ + NSDictionary *authorDict = [dict dictionaryForKey:PostRESTKeyAuthor]; + NSDictionary *discussionDict = [dict dictionaryForKey:PostRESTKeyDiscussion] ?: dict; + + self.authorID = [authorDict numberForKey:PostRESTKeyID]; + self.author = [self stringOrEmptyString:[authorDict stringForKey:PostRESTKeyNiceName]]; // typically the author's screen name + self.authorAvatarURL = [self stringOrEmptyString:[authorDict stringForKey:PostRESTKeyAvatarURL]]; + self.authorDisplayName = [[self stringOrEmptyString:[authorDict stringForKey:PostRESTKeyName]] wpkit_stringByDecodingXMLCharacters]; // Typically the author's given name + self.authorEmail = [self authorEmailFromAuthorDictionary:authorDict]; + self.authorURL = [self stringOrEmptyString:[authorDict stringForKey:PostRESTKeyURL]]; + self.siteIconURL = [self stringOrEmptyString:[dict stringForKeyPath:@"site_icon.img"]]; + if (self.siteIconURL.length == 0) { + self.siteIconURL = [self stringOrEmptyString:[dict stringForKeyPath:@"meta.data.site.icon.img"]]; + } + self.blogName = [self siteNameFromPostDictionary:dict]; + self.blogDescription = [self siteDescriptionFromPostDictionary:dict]; + self.blogURL = [self siteURLFromPostDictionary:dict]; + self.commentCount = [discussionDict numberForKey:PostRESTKeyCommentCount]; + self.commentsOpen = [[discussionDict numberForKey:PostRESTKeyCommentsOpen] boolValue]; + self.content = [self postContentFromPostDictionary:dict]; + self.date_created_gmt = [self stringOrEmptyString:[dict stringForKey:PostRESTKeyDate]]; + self.featuredImage = [self featuredImageFromPostDictionary:dict]; + self.autoSuggestedFeaturedImage = [self sanitizeFeaturedImageString:[self featuredMediaImageFromPostDictionary:dict]]; + self.suitableImageFromPostContent = [self sanitizeFeaturedImageString:[self suitableImageFromPostContent:dict]]; + self.feedID = [dict numberForKey:PostRESTKeyFeedID]; + self.feedItemID = [dict numberForKey:PostRESTKeyFeedItemID]; + self.globalID = [self stringOrEmptyString:[dict stringForKey:PostRESTKeyGlobalID]]; + self.isBlogAtomic = [self siteIsAtomicFromPostDictionary:dict]; + self.isBlogPrivate = [self siteIsPrivateFromPostDictionary:dict]; + self.isFollowing = [[dict numberForKey:PostRESTKeyIsFollowing] boolValue]; + self.isLiked = [[dict numberForKey:PostRESTKeyILike] boolValue]; + self.isReblogged = [[dict numberForKey:PostRESTKeyIsReblogged] boolValue]; + self.isWPCom = [self isWPComFromPostDictionary:dict]; + self.likeCount = [dict numberForKey:PostRESTKeyLikeCount]; + self.permalink = [self stringOrEmptyString:[dict stringForKey:PostRESTKeyURL]]; + self.postID = [dict numberForKey:PostRESTKeyID]; + self.postTitle = [self postTitleFromPostDictionary:dict]; + self.score = [dict numberForKey:PostRESTKeyScore]; + self.siteID = [dict numberForKey:PostRESTKeySiteID]; + self.sortDate = [self sortDateFromPostDictionary:dict]; + self.sortRank = @(self.sortDate.timeIntervalSinceReferenceDate); + self.status = [self stringOrEmptyString:[dict stringForKey:PostRESTKeyStatus]]; + self.summary = [self postSummaryFromPostDictionary:dict orPostContent:self.content]; + self.tags = [self tagsFromPostDictionary:dict]; + self.isSharingEnabled = [[dict numberForKey:PostRESTKeySharingEnabled] boolValue]; + self.isLikesEnabled = [[dict numberForKey:PostRESTKeyLikesEnabled] boolValue]; + self.organizationID = [dict numberForKeyPath:PostRESTKeyOrganizationID] ?: @0; + self.canSubscribeComments = [[dict numberForKey:PostRESTKeyCanSubscribeComments] boolValue]; + self.isSubscribedComments = [[dict numberForKey:PostRESTKeyIsSubscribedComments] boolValue]; + self.receivesCommentNotifications = [[dict numberForKey:POSTRESTKeyReceivesCommentNotifications] boolValue]; + + if ([dict numberForKey:PostRESTKeyIsSeen]) { + self.isSeen = [[dict numberForKey:PostRESTKeyIsSeen] boolValue]; + self.isSeenSupported = YES; + } else { + self.isSeen = YES; + self.isSeenSupported = NO; + } + + // Construct a title if necessary. + if ([self.postTitle length] == 0 && [self.summary length] > 0) { + self.postTitle = [self titleFromSummary:self.summary]; + } + + NSDictionary *tags = [self primaryAndSecondaryTagsFromPostDictionary:dict]; + if (tags) { + self.primaryTag = [tags stringForKey:TagKeyPrimary]; + self.primaryTagSlug = [tags stringForKey:TagKeyPrimarySlug]; + self.secondaryTag = [tags stringForKey:TagKeySecondary]; + self.secondaryTagSlug = [tags stringForKey:TagKeySecondarySlug]; + } + + self.isExternal = [[dict numberForKey:PostRESTKeyIsExternal] boolValue]; + self.isJetpack = [[dict numberForKey:PostRESTKeyIsJetpack] boolValue]; + self.wordCount = [dict numberForKey:PostRESTKeyWordCount]; + self.readingTime = [self readingTimeForWordCount:self.wordCount]; + + NSDictionary *railcar = [dict dictionaryForKey:PostRESTKeyRailcar]; + if (railcar) { + NSError *error; + NSData *railcarData = [NSJSONSerialization dataWithJSONObject:railcar options:NSJSONWritingPrettyPrinted error:&error]; + self.railcar = [[NSString alloc] initWithData:railcarData encoding:NSUTF8StringEncoding]; + } + + if ([dict arrayForKeyPath:@"discover_metadata.discover_fp_post_formats"]) { + self.sourceAttribution = [self sourceAttributionFromDictionary:[dict dictionaryForKey:PostRESTKeyDiscoverMetadata]]; + } + + RemoteReaderCrossPostMeta *crossPostMeta = [self crossPostMetaFromPostDictionary:dict]; + if (crossPostMeta) { + self.crossPostMeta = crossPostMeta; + } + + return self; +} + +- (RemoteReaderCrossPostMeta *)crossPostMetaFromPostDictionary:(NSDictionary *)dict +{ + BOOL crossPostMetaFound = NO; + + RemoteReaderCrossPostMeta *meta = [RemoteReaderCrossPostMeta new]; + + NSArray *metadata = [dict arrayForKey:PostRESTKeyMetadata]; + for (NSDictionary *obj in metadata) { + if (![obj isKindOfClass:[NSDictionary class]]) { + continue; + } + if ([[obj stringForKey:CrossPostMetaKey] isEqualToString:CrossPostMetaXPostPermalink] || + [[obj stringForKey:CrossPostMetaKey] isEqualToString:CrossPostMetaXCommentPermalink]) { + + NSString *path = [obj stringForKey:CrossPostMetaValue]; + NSURL *url = [NSURL URLWithString:path]; + if (url) { + meta.siteURL = [NSString stringWithFormat:@"%@://%@", url.scheme, url.host]; + meta.postURL = [NSString stringWithFormat:@"%@%@", meta.siteURL, url.path]; + if ([url.fragment hasPrefix:CrossPostMetaCommentPrefix]) { + meta.commentURL = [url absoluteString]; + } + } + } else if ([[obj stringForKey:CrossPostMetaKey] isEqualToString:CrossPostMetaXPostOrigin]) { + NSString *value = [obj stringForKey:CrossPostMetaValue]; + NSArray *IDS = [value componentsSeparatedByString:@":"]; + meta.siteID = [[IDS firstObject] wpkit_numericValue]; + meta.postID = [[IDS lastObject] wpkit_numericValue]; + + crossPostMetaFound = YES; + } + } + + if (!crossPostMetaFound) { + return nil; + } + + return meta; +} + +- (NSDictionary *)primaryAndSecondaryTagsFromPostDictionary:(NSDictionary *)dict +{ + NSString *primaryTag = @""; + NSString *primaryTagSlug = @""; + NSString *secondaryTag = @""; + NSString *secondaryTagSlug = @""; + NSString *editorialTag; + NSString *editorialSlug; + + // Loop over all the tags. + // If the current tag's post count is greater than the previous post count, + // make it the new primary tag, and make a previous primary tag the secondary tag. + NSArray *remoteTags = [[dict dictionaryForKey:PostRESTKeyTags] allValues]; + if (remoteTags) { + NSInteger highestCount = 0; + NSInteger secondHighestCount = 0; + NSString *tagTitle; + for (NSDictionary *tag in remoteTags) { + NSInteger count = [[tag numberForKey:PostRESTKeyPostCount] integerValue]; + if (count > highestCount) { + secondaryTag = primaryTag; + secondaryTagSlug = primaryTagSlug; + secondHighestCount = highestCount; + + tagTitle = [tag stringForKey:POSTRESTKeyTagDisplayName] ?: [tag stringForKey:PostRESTKeyName]; + primaryTag = tagTitle ?: @""; + primaryTagSlug = [tag stringForKey:PostRESTKeySlug] ?: @""; + highestCount = count; + + } else if (count > secondHighestCount) { + tagTitle = [tag stringForKey:POSTRESTKeyTagDisplayName] ?: [tag stringForKey:PostRESTKeyName]; + secondaryTag = tagTitle ?: @""; + secondaryTagSlug = [tag stringForKey:PostRESTKeySlug] ?: @""; + secondHighestCount = count; + + } + } + } + + NSDictionary *editorial = [dict dictionaryForKey:PostRESTKeyEditorial]; + if (editorial) { + editorialSlug = [editorial stringForKey:PostRESTKeyHighlightTopic]; + editorialTag = [editorial stringForKey:PostRESTKeyHighlightTopicTitle] ?: [editorialSlug capitalizedString]; + } + + if (editorialSlug) { + secondaryTag = primaryTag; + secondaryTagSlug = primaryTagSlug; + primaryTag = editorialTag; + primaryTagSlug = editorialSlug; + } + + primaryTag = [primaryTag wpkit_stringByDecodingXMLCharacters]; + secondaryTag = [secondaryTag wpkit_stringByDecodingXMLCharacters]; + + return @{ + TagKeyPrimary:primaryTag, + TagKeyPrimarySlug:primaryTagSlug, + TagKeySecondary:secondaryTag, + TagKeySecondarySlug:secondaryTagSlug, + }; +} + +- (NSNumber *)readingTimeForWordCount:(NSNumber *)wordCount +{ + NSInteger count = [wordCount integerValue]; + NSInteger minutesToRead = count / AvgWordsPerMinuteRead; + if (minutesToRead < MinutesToReadThreshold) { + return @(0); + } + return @(minutesToRead); +} + +/** + Composes discover attribution if needed. + + @param dict A dictionary representing a discover_metadata object from the REST API + @return A `RemoteDiscoverAttribution` object + */ +- (RemoteSourcePostAttribution *)sourceAttributionFromDictionary:(NSDictionary *)dict +{ + NSArray *taxonomies = [dict arrayForKey:@"discover_fp_post_formats"]; + if ([taxonomies count] == 0) { + return nil; + } + + RemoteSourcePostAttribution *sourceAttr = [RemoteSourcePostAttribution new]; + sourceAttr.permalink = [dict stringForKey:PostRESTKeyPermalink]; + sourceAttr.authorName = [dict stringForKeyPath:@"attribution.author_name"]; + sourceAttr.authorURL = [dict stringForKeyPath:@"attribution.author_url"]; + sourceAttr.avatarURL = [dict stringForKeyPath:@"attribution.avatar_url"]; + sourceAttr.blogName = [dict stringForKeyPath:@"attribution.blog_name"]; + sourceAttr.blogURL = [dict stringForKeyPath:@"attribution.blog_url"]; + sourceAttr.blogID = [dict numberForKeyPath:@"featured_post_wpcom_data.blog_id"]; + sourceAttr.postID = [dict numberForKeyPath:@"featured_post_wpcom_data.post_id"]; + sourceAttr.commentCount = [dict numberForKeyPath:@"featured_post_wpcom_data.comment_count"]; + sourceAttr.likeCount = [dict numberForKeyPath:@"featured_post_wpcom_data.like_count"]; + sourceAttr.taxonomies = [self slugsFromDiscoverPostTaxonomies:taxonomies]; + return sourceAttr; +} + + +#pragma mark - Utils + +/** + Checks the value of the string passed. If the string is nil, an empty string is returned. + + @param str The string to check for nil. + @ Returns the string passed if it was not nil, or an empty string if the value passed was nil. + */ +- (NSString *)stringOrEmptyString:(NSString *)str +{ + if (!str) { + return @""; + } + return str; +} + +/** + Format a featured image url into an expected format. + + @param img The URL path to the featured image. + @return A sanitized URL. + */ +- (NSString *)sanitizeFeaturedImageString:(NSString *)img +{ + if (!img) { + return [NSString string]; + } + NSRange mshotRng = [img rangeOfString:@"wp.com/mshots/"]; + if (NSNotFound != mshotRng.location) { + // MShots are sceen caps of the actual site. There URLs look like this: + // https://s0.wp.com/mshots/v1/http%3A%2F%2Fsitename.wordpress.com%2F2013%2F05%2F13%2Fr-i-p-mom%2F?w=252 + // We want the mshot URL but not the size info in the query string. + NSRange rng = [img rangeOfString:@"?" options:NSBackwardsSearch]; + if (rng.location != NSNotFound) { + img = [img substringWithRange:NSMakeRange(0, rng.location)]; + } + return img; + } + + NSRange imgPressRng = [img rangeOfString:@"wp.com/imgpress"]; + if (imgPressRng.location != NSNotFound) { + // ImagePress urls look like this: + // https://s0.wp.com/imgpress?resize=252%2C160&url=http%3A%2F%2Fsitename.files.wordpress.com%2F2014%2F04%2Fimage-name.jpg&unsharpmask=80,0.5,3 + // We want the URL of the image being sent to ImagePress without all the ImagePress stuff + + // Find the start of the actual URL for the image + NSRange httpRng = [img rangeOfString:@"http" options:NSBackwardsSearch]; + NSInteger location = 0; + if (httpRng.location != NSNotFound) { + location = httpRng.location; + } + + // Find the last of the image press options after the image URL + // Search from the start of the URL to the end of the string + NSRange ampRng = [img rangeOfString:@"&" options:NSLiteralSearch range:NSMakeRange(location, [img length] - location)]; + // Default length is the remainder of the string following the start of the image URL. + NSInteger length = [img length] - location; + if (ampRng.location != NSNotFound) { + // The actual length is the location of the first ampersand after the starting index of the image URL, minus the starting index of the image URL. + length = ampRng.location - location; + } + + // Retrieve the image URL substring from the range. + img = [img substringWithRange:NSMakeRange(location, length)]; + + // Actually decode twice to remove the encodings + img = [img stringByRemovingPercentEncoding]; + img = [img stringByRemovingPercentEncoding]; + } + return img; +} + +#pragma mark - Data sanitization methods + +/** + The v1 API result is inconsistent in that it will return a 0 when there is no author email. + + @param dict The author dictionary. + @return The author's email address or an empty string. + */ +- (NSString *)authorEmailFromAuthorDictionary:(NSDictionary *)dict +{ + NSString *authorEmail = [dict stringForKey:PostRESTKeyEmail]; + + // if 0 or less than minimum email length. a@a.aa + if ([authorEmail isEqualToString:@"0"] || [authorEmail length] < 6) { + authorEmail = @""; + } + + return authorEmail; +} + +/** + Parse whether the post belongs to a wpcom blog. + + @param dict A dictionary representing a post object from the REST API + @return YES if the post belongs to a wpcom blog, else NO + */ +- (BOOL)isWPComFromPostDictionary:(NSDictionary *)dict +{ + BOOL isExternal = [[dict numberForKey:PostRESTKeyIsExternal] boolValue]; + BOOL isJetpack = [[dict numberForKey:PostRESTKeyIsJetpack] boolValue]; + + return !isJetpack && !isExternal; +} + +/** + Get the tags assigned to a post and return them as a comma separated string. + + @param dict A dictionary representing a post object from the REST API. + @return A comma separated list of tags, or an empty string if no tags are found. + */ +- (NSString *)tagsFromPostDictionary:(NSDictionary *)dict +{ + NSDictionary *tagsDict = [dict dictionaryForKey:PostRESTKeyTags]; + NSArray *tagsList = [NSArray arrayWithArray:[tagsDict allKeys]]; + NSString *tags = [tagsList componentsJoinedByString:@", "]; + if (tags == nil) { + tags = @""; + } + return tags; +} + +/** + Get the date the post should be sorted by. + + @param dict A dictionary representing a post object from the REST API. + @return The NSDate that should be used when sorting the post. + */ +- (NSDate *)sortDateFromPostDictionary:(NSDictionary *)dict +{ + // Sort date varies depending on the endpoint we're fetching from. + NSString *sortDate = [self stringOrEmptyString:[dict stringForKey:PostRESTKeyDate]]; + + // Date tagged on is returned by read/tags/%s/posts endpoints. + NSString *taggedDate = [dict stringForKey:PostRESTKeyTaggedOn]; + if (taggedDate != nil) { + sortDate = taggedDate; + } + + // Date liked is returned by the read/liked end point. Use this for sorting recent likes. + NSString *likedDate = [dict stringForKey:PostRESTKeyDateLiked]; + if (likedDate != nil) { + sortDate = likedDate; + } + + // Values set in editorial trumps the rest + NSString *editorialDate = [dict stringForKeyPath:@"editorial.displayed_on"]; + if (editorialDate != nil) { + sortDate = editorialDate; + } + + return [WPKitDateUtils dateFromISOString:sortDate]; +} + +/** + Get the url path of the featured image to use for a post. + + @param dict A dictionary representing a post object from the REST API. + @return The url path for the featured image or an empty string. + */ +- (NSString *)featuredImageFromPostDictionary:(NSDictionary *)dict +{ + // Editorial trumps all + NSString *featuredImage = [self editorialImageFromPostDictionary:dict]; + + // Second option is the user specified featured image + if ([featuredImage length] == 0) { + featuredImage = [self userSpecifiedFeaturedImageFromPostDictionary:dict]; + } + + featuredImage = [self sanitizeFeaturedImageString:featuredImage]; + + return featuredImage; +} + +- (NSString *)editorialImageFromPostDictionary:(NSDictionary *)dict { + return [dict stringForKeyPath:@"editorial.image"]; +} + +- (NSString *)userSpecifiedFeaturedImageFromPostDictionary:(NSDictionary *)dict { + return [dict stringForKey:PostRESTKeyFeaturedImage]; +} + +- (NSString *)featuredMediaImageFromPostDictionary:(NSDictionary *)dict { + NSDictionary *featuredMedia = [dict dictionaryForKey:PostRESTKeyFeaturedMedia]; + if ([[featuredMedia stringForKey:@"type"] isEqualToString:@"image"]) { + return [featuredMedia stringForKey:@"uri"]; + } + return nil; +} + +- (NSString *)suitableImageFromPostContent:(NSDictionary *)dict { + NSString *content = [dict stringForKey:PostRESTKeyContent]; + NSString *imageToDisplay = [WPKitDisplayableImageHelper searchPostContentForImageToDisplay:content]; + return [self stringOrEmptyString:imageToDisplay]; +} + +/** + Get the name of the post's site. + + @param dict A dictionary representing a post object from the REST API. + @return The name of the post's site or an empty string. + */ +- (NSString *)siteNameFromPostDictionary:(NSDictionary *)dict +{ + // Blog Name + NSString *siteName = [self stringOrEmptyString:[dict stringForKey:PostRESTKeySiteName]]; + + // For some endpoints blogname is defined in meta + NSString *metaBlogName = [dict stringForKeyPath:@"meta.data.site.name"]; + if (metaBlogName != nil) { + siteName = metaBlogName; + } + + // Values set in editorial trumps the rest + NSString *editorialSiteName = [dict stringForKeyPath:@"editorial.blog_name"]; + if (editorialSiteName != nil) { + siteName = editorialSiteName; + } + + return [self makePlainText:siteName]; +} + +/** + Get the description of the post's site. + + @param dict A dictionary representing a post object from the REST API. + @return The description of the post's site or an empty string. + */ +- (NSString *)siteDescriptionFromPostDictionary:(NSDictionary *)dict +{ + NSString *description = [self stringOrEmptyString:[dict stringForKeyPath:@"meta.data.site.description"]]; + return [self makePlainText:description]; +} + +/** + Retrives the post site's URL + + @param dict A dictionary representing a post object from the REST API. + @return The URL path of the post's site. + */ +- (NSString *)siteURLFromPostDictionary:(NSDictionary *)dict +{ + NSString *siteURL = [self stringOrEmptyString:[dict stringForKey:PostRESTKeySiteURL]]; + + NSString *metaSiteURL = [dict stringForKeyPath:@"meta.data.site.URL"]; + if (metaSiteURL != nil) { + siteURL = metaSiteURL; + } + + return siteURL; +} + +/** + Retrives the post content from results dictionary + + @param dict A dictionary representing a post object from the REST API. + @return The formatted post content. + */ +- (NSString *)postContentFromPostDictionary:(NSDictionary *)dict { + NSString *content = [self stringOrEmptyString:[dict stringForKey:PostRESTKeyContent]]; + + return content; +} + +/** + Get the title of the post + + @param dict A dictionary representing a post object from the REST API. + @return The title of the post or an empty string. + */ +- (NSString *)postTitleFromPostDictionary:(NSDictionary *)dict { + NSString *title = [self stringOrEmptyString:[dict stringForKey:PostRESTKeyTitle]]; + return [self makePlainText:title]; +} + +/** + Get the summary for the post, or crafts one from the post content. + + @param dict A dictionary representing a post object from the REST API. + @param content The formatted post content. + @return The summary for the post or an empty string. + */ +- (NSString *)postSummaryFromPostDictionary:(NSDictionary *)dict orPostContent:(NSString *)content { + NSString *summary = [self stringOrEmptyString:[dict stringForKey:PostRESTKeyExcerpt]]; + summary = [self formatSummary:summary]; + if (!summary) { + summary = [self createSummaryFromContent:content]; + } + return summary; +} + +- (BOOL)siteIsAtomicFromPostDictionary:(NSDictionary *)dict +{ + NSNumber *isAtomic = [dict numberForKey:PostRESTKeySiteIsAtomic]; + + return [isAtomic boolValue]; +} + +/** + Retrives the privacy preference for the post's site. + + @param dict A dictionary representing a post object from the REST API. + @return YES if the site is private. + */ +- (BOOL)siteIsPrivateFromPostDictionary:(NSDictionary *)dict +{ + NSNumber *isPrivate = [dict numberForKey:PostRESTKeySiteIsPrivate]; + + NSNumber *metaIsPrivate = [dict numberForKeyPath:@"meta.data.site.is_private"]; + if (metaIsPrivate != nil) { + isPrivate = metaIsPrivate; + } + + return [isPrivate boolValue]; +} + +- (NSArray *)slugsFromDiscoverPostTaxonomies:(NSArray *)discoverPostTaxonomies +{ + return [discoverPostTaxonomies wpkit_map:^id(NSDictionary *dict) { + return [dict stringForKey:PostRESTKeySlug]; + }]; +} + + +#pragma mark - Content Formatting and Sanitization + +/** + Formats a post's summary. The excerpts provided by the REST API contain HTML and have some extra content appened to the end. + HTML is stripped and the extra bit is removed. + + @param summary The summary to format. + @return The formatted summary. + */ +- (NSString *)formatSummary:(NSString *)summary +{ + summary = [self makePlainText:summary]; + + NSString *continueReading = NSLocalizedString(@"Continue reading", @"Part of a prompt suggesting that there is more content for the user to read."); + continueReading = [NSString stringWithFormat:@"%@ →", continueReading]; + + NSRange rng = [summary rangeOfString:continueReading options:NSCaseInsensitiveSearch]; + if (rng.location != NSNotFound) { + summary = [summary substringToIndex:rng.location]; + } + + return summary; +} + +/** + Create a summary for the post based on the post's content. + + @param string The post's content string. This should be the formatted content string. + @return A summary for the post. + */ +- (NSString *)createSummaryFromContent:(NSString *)string +{ + return [string wpkit_summarized]; +} + +/** + Transforms the specified string to plain text. HTML markup is removed and HTML entities are decoded. + + @param string The string to transform. + @return The transformed string. + */ +- (NSString *)makePlainText:(NSString *)string +{ + return [string wpkit_summarized]; +} + +/** + Creates a title for the post from the post's summary. + + @param summary The already formatted post summary. + @return A title for the post that is a snippet of the summary. + */ +- (NSString *)titleFromSummary:(NSString *)summary +{ + return [summary wpkit_stringByEllipsizingWithMaxLength:ReaderPostTitleLength preserveWords:YES]; +} + + +@end diff --git a/Modules/Sources/WordPressKitObjC/RemoteSourcePostAttribution.m b/Modules/Sources/WordPressKitObjC/RemoteSourcePostAttribution.m new file mode 100644 index 000000000000..99f7d3f41d35 --- /dev/null +++ b/Modules/Sources/WordPressKitObjC/RemoteSourcePostAttribution.m @@ -0,0 +1,5 @@ +#import "RemoteSourcePostAttribution.h" + +@implementation RemoteSourcePostAttribution + +@end diff --git a/Modules/Sources/WordPressKitObjC/RemoteTaxonomyPaging.m b/Modules/Sources/WordPressKitObjC/RemoteTaxonomyPaging.m new file mode 100644 index 000000000000..b795c5f7319f --- /dev/null +++ b/Modules/Sources/WordPressKitObjC/RemoteTaxonomyPaging.m @@ -0,0 +1,5 @@ +#import "RemoteTaxonomyPaging.h" + +@implementation RemoteTaxonomyPaging + +@end diff --git a/Modules/Sources/WordPressKitObjC/RemoteTheme.m b/Modules/Sources/WordPressKitObjC/RemoteTheme.m new file mode 100644 index 000000000000..5b6c2e83ef56 --- /dev/null +++ b/Modules/Sources/WordPressKitObjC/RemoteTheme.m @@ -0,0 +1,5 @@ +#import "RemoteTheme.h" + +@implementation RemoteTheme + +@end diff --git a/Modules/Sources/WordPressKitObjC/ServiceRemoteWordPressComREST.m b/Modules/Sources/WordPressKitObjC/ServiceRemoteWordPressComREST.m new file mode 100644 index 000000000000..c88f660301a7 --- /dev/null +++ b/Modules/Sources/WordPressKitObjC/ServiceRemoteWordPressComREST.m @@ -0,0 +1,25 @@ +#import "ServiceRemoteWordPressComREST.h" +#import "WordPressComRESTAPIVersionedPathBuilder.h" + +@implementation ServiceRemoteWordPressComREST + +- (instancetype)initWithWordPressComRestApi:(id)wordPressComRestApi { + self = [super init]; + if (self) { + _wordPressComRESTAPI = wordPressComRestApi; + } + return self; +} + +#pragma mark - Request URL construction + +- (NSString *)pathForEndpoint:(NSString *)resourceUrl + withVersion:(WordPressComRESTAPIVersion)apiVersion +{ + NSParameterAssert([resourceUrl isKindOfClass:[NSString class]]); + + return [WordPressComRESTAPIVersionedPathBuilder pathForEndpoint:resourceUrl + withVersion:apiVersion]; +} + +@end diff --git a/Modules/Sources/WordPressKitObjC/ServiceRemoteWordPressXMLRPC.m b/Modules/Sources/WordPressKitObjC/ServiceRemoteWordPressXMLRPC.m new file mode 100644 index 000000000000..dd8673a6c549 --- /dev/null +++ b/Modules/Sources/WordPressKitObjC/ServiceRemoteWordPressXMLRPC.m @@ -0,0 +1,69 @@ +#import "ServiceRemoteWordPressXMLRPC.h" + +@interface ServiceRemoteWordPressXMLRPC() + +@property (nonatomic, strong, readwrite) id api; +@property (nonatomic, copy) NSString *username; +@property (nonatomic, copy) NSString *password; + +@end + +@implementation ServiceRemoteWordPressXMLRPC + +- (id)initWithApi:(id)api username:(NSString *)username password:(NSString *)password +{ + NSParameterAssert(api != nil); + NSParameterAssert(username != nil); + NSParameterAssert(password != nil); + if (username == nil || password == nil) { + return nil; + } + + self = [super init]; + if (self) { + _api = api; + _username = username; + _password = password; + } + return self; +} + +/** + Common XML-RPC arguments to most calls + + Most XML-RPC calls will take blog ID, username, and password as their first arguments. + Blog ID is unused since the blog is inferred from the XML-RPC endpoint. We send a value of 0 + because the documentation expects an int value, and we have to send something. + + See https://github.com/WordPress/WordPress/blob/master/wp-includes/class-wp-xmlrpc-server.php + for method documentation. + */ +- (NSArray *)defaultXMLRPCArguments { + return @[@0, self.username, self.password]; +} + +- (NSArray *)XMLRPCArgumentsWithExtra:(id)extra { + NSMutableArray *result = [[self defaultXMLRPCArguments] mutableCopy]; + if ([extra isKindOfClass:[NSArray class]]) { + [result addObjectsFromArray:extra]; + } else if (extra != nil) { + [result addObject:extra]; + } + + return [NSArray arrayWithArray:result]; +} + +- (NSArray *)XMLRPCArgumentsWithExtraDefaults:(NSArray *)extraDefaults andExtra:(_Nullable id)extra { + NSMutableArray *result = [[self defaultXMLRPCArguments] mutableCopy]; + [result addObjectsFromArray:extraDefaults]; + + if ([extra isKindOfClass:[NSArray class]]) { + [result addObjectsFromArray:extra]; + } else if (extra != nil) { + [result addObject:extra]; + } + + return [NSArray arrayWithArray:result]; +} + +@end diff --git a/Modules/Sources/WordPressKitObjC/SiteServiceRemoteWordPressComREST.m b/Modules/Sources/WordPressKitObjC/SiteServiceRemoteWordPressComREST.m new file mode 100644 index 000000000000..cc6de13b2cab --- /dev/null +++ b/Modules/Sources/WordPressKitObjC/SiteServiceRemoteWordPressComREST.m @@ -0,0 +1,17 @@ +#import "SiteServiceRemoteWordPressComREST.h" + +@interface SiteServiceRemoteWordPressComREST () +@property (nonatomic, strong) NSNumber *siteID; +@end + +@implementation SiteServiceRemoteWordPressComREST + +- (instancetype)initWithWordPressComRestApi:(id)api siteID:(NSNumber *)siteID { + self = [super initWithWordPressComRestApi:api]; + if (self) { + _siteID = siteID; + } + return self; +} + +@end diff --git a/Modules/Sources/WordPressKitObjC/TaxonomyServiceRemoteREST.m b/Modules/Sources/WordPressKitObjC/TaxonomyServiceRemoteREST.m new file mode 100644 index 000000000000..f3cb3b33df4f --- /dev/null +++ b/Modules/Sources/WordPressKitObjC/TaxonomyServiceRemoteREST.m @@ -0,0 +1,358 @@ +#import "TaxonomyServiceRemoteREST.h" +#import "RemotePostTag.h" +#import "RemoteTaxonomyPaging.h" +#import "RemotePostCategory.h" +#import "WPMapFilterReduce.h" +#import "WPKitLogging.h" +@import NSObject_SafeExpectations; + +NS_ASSUME_NONNULL_BEGIN + +static NSString * const TaxonomyRESTCategoryIdentifier = @"categories"; +static NSString * const TaxonomyRESTTagIdentifier = @"tags"; + +static NSString * const TaxonomyRESTIDParameter = @"ID"; +static NSString * const TaxonomyRESTNameParameter = @"name"; +static NSString * const TaxonomyRESTSlugParameter = @"slug"; +static NSString * const TaxonomyRESTDescriptionParameter = @"description"; +static NSString * const TaxonomyRESTPostCountParameter = @"post_count"; +static NSString * const TaxonomyRESTParentParameter = @"parent"; +static NSString * const TaxonomyRESTSearchParameter = @"search"; +static NSString * const TaxonomyRESTOrderParameter = @"order"; +static NSString * const TaxonomyRESTOrderByParameter = @"order_by"; +static NSString * const TaxonomyRESTNumberParameter = @"number"; +static NSString * const TaxonomyRESTOffsetParameter = @"offset"; +static NSString * const TaxonomyRESTPageParameter = @"page"; + +static NSUInteger const TaxonomyRESTNumberMaxValue = 1000; + +@implementation TaxonomyServiceRemoteREST + +#pragma mark - categories + +- (void)createCategory:(RemotePostCategory *)category + success:(nullable void (^)(RemotePostCategory *))success + failure:(nullable void (^)(NSError *))failure +{ + NSParameterAssert(category.name.length > 0); + + NSMutableDictionary *parameters = [NSMutableDictionary dictionary]; + parameters[TaxonomyRESTNameParameter] = category.name; + if (category.parentID) { + parameters[TaxonomyRESTParentParameter] = category.parentID; + } + + [self createTaxonomyWithType:TaxonomyRESTCategoryIdentifier + parameters:parameters + success:^(NSDictionary *taxonomyDictionary) { + RemotePostCategory *receivedCategory = [self remoteCategoryWithJSONDictionary:taxonomyDictionary]; + if (success) { + success(receivedCategory); + } + } failure:failure]; +} + +- (void)getCategoriesWithSuccess:(void (^)(NSArray *))success + failure:(nullable void (^)(NSError *))failure +{ + [self getTaxonomyWithType:TaxonomyRESTCategoryIdentifier + parameters:@{TaxonomyRESTNumberParameter: @(TaxonomyRESTNumberMaxValue)} + success:^(NSDictionary *responseObject) { + success([self remoteCategoriesWithJSONArray:[responseObject arrayForKey:TaxonomyRESTCategoryIdentifier]]); + } failure:failure]; +} + +- (void)getCategoriesWithPaging:(RemoteTaxonomyPaging *)paging + success:(void (^)(NSArray *categories))success + failure:(nullable void (^)(NSError *error))failure +{ + [self getTaxonomyWithType:TaxonomyRESTCategoryIdentifier + parameters:[self parametersForPaging:paging] + success:^(NSDictionary *responseObject) { + success([self remoteCategoriesWithJSONArray:[responseObject arrayForKey:TaxonomyRESTCategoryIdentifier]]); + } failure:failure]; +} + +- (void)searchCategoriesWithName:(NSString *)nameQuery + success:(void (^)(NSArray *tags))success + failure:(nullable void (^)(NSError *error))failure +{ + NSParameterAssert(nameQuery.length > 0); + [self getTaxonomyWithType:TaxonomyRESTCategoryIdentifier + parameters:@{TaxonomyRESTSearchParameter: nameQuery} + success:^(NSDictionary *responseObject) { + success([self remoteCategoriesWithJSONArray:[responseObject arrayForKey:TaxonomyRESTCategoryIdentifier]]); + } failure:failure]; +} + +#pragma mark - tags + +- (void)createTag:(RemotePostTag *)tag + success:(nullable void (^)(RemotePostTag *tag))success + failure:(nullable void (^)(NSError *error))failure +{ + NSParameterAssert(tag.name.length > 0); + + NSMutableDictionary *parameters = [NSMutableDictionary dictionary]; + parameters[TaxonomyRESTNameParameter] = tag.name; + parameters[TaxonomyRESTDescriptionParameter] = tag.tagDescription; + + [self createTaxonomyWithType:TaxonomyRESTTagIdentifier + parameters:parameters + success:^(NSDictionary *taxonomyDictionary) { + RemotePostTag *receivedTag = [self remoteTagWithJSONDictionary:taxonomyDictionary]; + if (success) { + success(receivedTag); + } + } failure:failure]; +} + +- (void)updateTag:(RemotePostTag *)tag + success:(nullable void (^)(RemotePostTag *tag))success + failure:(nullable void (^)(NSError *error))failure +{ + NSParameterAssert(tag.name.length > 0); + + NSMutableDictionary *parameters = [NSMutableDictionary dictionary]; + parameters[TaxonomyRESTSlugParameter] = tag.slug; + parameters[TaxonomyRESTNameParameter] = tag.name; + parameters[TaxonomyRESTDescriptionParameter] = tag.tagDescription; + + [self updateTaxonomyWithType:TaxonomyRESTTagIdentifier + parameters:parameters success:^(NSDictionary * _Nonnull responseObject) { + if (success) { + RemotePostTag *receivedTag = [self remoteTagWithJSONDictionary:responseObject]; + success(receivedTag); + } + } failure:failure]; +} + +- (void)deleteTag:(RemotePostTag *)tag + success:(nullable void (^)(void))success + failure:(nullable void (^)(NSError *error))failure +{ + NSParameterAssert(tag.name.length > 0); + + NSMutableDictionary *parameters = [NSMutableDictionary dictionary]; + parameters[TaxonomyRESTSlugParameter] = tag.slug; + + [self deleteTaxonomyWithType:TaxonomyRESTTagIdentifier parameters:parameters success:^(NSDictionary * _Nonnull responseObject) { + if (success) { + success(); + } + } failure:failure]; +} + +- (void)getTagsWithSuccess:(void (^)(NSArray *tags))success + failure:(nullable void (^)(NSError *error))failure +{ + [self getTaxonomyWithType:TaxonomyRESTTagIdentifier + parameters:@{TaxonomyRESTNumberParameter: @(TaxonomyRESTNumberMaxValue)} + success:^(NSDictionary *responseObject) { + success([self remoteTagsWithJSONArray:[responseObject arrayForKey:TaxonomyRESTTagIdentifier]]); + } failure:failure]; +} + +- (void)getTagsWithPaging:(RemoteTaxonomyPaging *)paging + success:(void (^)(NSArray *tags))success + failure:(nullable void (^)(NSError *error))failure +{ + [self getTaxonomyWithType:TaxonomyRESTTagIdentifier + parameters:[self parametersForPaging:paging] + success:^(NSDictionary *responseObject) { + success([self remoteTagsWithJSONArray:[responseObject arrayForKey:TaxonomyRESTTagIdentifier]]); + } failure:failure]; +} + +- (void)searchTagsWithName:(NSString *)nameQuery + success:(void (^)(NSArray *tags))success + failure:(nullable void (^)(NSError *error))failure +{ + NSParameterAssert(nameQuery.length > 0); + [self getTaxonomyWithType:TaxonomyRESTTagIdentifier + parameters:@{TaxonomyRESTSearchParameter: nameQuery} + success:^(NSDictionary *responseObject) { + success([self remoteTagsWithJSONArray:[responseObject arrayForKey:TaxonomyRESTTagIdentifier]]); + } failure:failure]; +} + +#pragma mark - default methods + +- (void)createTaxonomyWithType:(NSString *)typeIdentifier + parameters:(nullable NSDictionary *)parameters + success:(void (^)(NSDictionary *taxonomyDictionary))success + failure:(nullable void (^)(NSError *error))failure +{ + NSString *path = [NSString stringWithFormat:@"sites/%@/%@/new?context=edit", self.siteID, typeIdentifier]; + NSString *requestUrl = [self pathForEndpoint:path withVersion:WordPressComRESTAPIVersion_1_1]; + + [self.wordPressComRESTAPI post:requestUrl + parameters:parameters + success:^(id _Nonnull responseObject, NSHTTPURLResponse *httpResponse) { + if (![responseObject isKindOfClass:[NSDictionary class]]) { + NSString *message = [NSString stringWithFormat:@"Invalid response creating taxonomy of type: %@", typeIdentifier]; + [self handleResponseErrorWithMessage:message url:requestUrl failure:failure]; + return; + } + success(responseObject); + } failure:^(NSError * _Nonnull error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + +- (void)getTaxonomyWithType:(NSString *)typeIdentifier + parameters:(nullable NSDictionary *)parameters + success:(void (^)(NSDictionary *responseObject))success + failure:(nullable void (^)(NSError *error))failure +{ + NSString *path = [NSString stringWithFormat:@"sites/%@/%@?context=edit", self.siteID, typeIdentifier]; + NSString *requestUrl = [self pathForEndpoint:path + withVersion:WordPressComRESTAPIVersion_1_1]; + + [self.wordPressComRESTAPI get:requestUrl + parameters:parameters + success:^(id _Nonnull responseObject, NSHTTPURLResponse *httpResponse) { + if (![responseObject isKindOfClass:[NSDictionary class]]) { + NSString *message = [NSString stringWithFormat:@"Invalid response requesting taxonomy of type: %@", typeIdentifier]; + [self handleResponseErrorWithMessage:message url:requestUrl failure:failure]; + return; + } + success(responseObject); + } failure:^(NSError * _Nonnull error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + +- (void)deleteTaxonomyWithType:(NSString *)typeIdentifier + parameters:(nullable NSDictionary *)parameters + success:(void (^)(NSDictionary *responseObject))success + failure:(nullable void (^)(NSError *error))failure +{ + NSString *path = [NSString stringWithFormat:@"sites/%@/%@/slug:%@/delete?context=edit", self.siteID, typeIdentifier, parameters[TaxonomyRESTSlugParameter]]; + NSString *requestUrl = [self pathForEndpoint:path + withVersion:WordPressComRESTAPIVersion_1_1]; + + [self.wordPressComRESTAPI post:requestUrl + parameters:nil + success:^(id _Nonnull responseObject, NSHTTPURLResponse *httpResponse) { + if (![responseObject isKindOfClass:[NSDictionary class]]) { + NSString *message = [NSString stringWithFormat:@"Invalid response deleting taxonomy of type: %@", typeIdentifier]; + [self handleResponseErrorWithMessage:message url:requestUrl failure:failure]; + return; + } + success(responseObject); + } failure:^(NSError * _Nonnull error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + +- (void)updateTaxonomyWithType:(NSString *)typeIdentifier + parameters:(nullable NSDictionary *)parameters + success:(void (^)(NSDictionary *responseObject))success + failure:(nullable void (^)(NSError *error))failure +{ + NSString *path = [NSString stringWithFormat:@"sites/%@/%@/slug:%@?context=edit", self.siteID, typeIdentifier, parameters[TaxonomyRESTSlugParameter]]; + NSString *requestUrl = [self pathForEndpoint:path + withVersion:WordPressComRESTAPIVersion_1_1]; + + [self.wordPressComRESTAPI post:requestUrl + parameters:parameters + success:^(id _Nonnull responseObject, NSHTTPURLResponse *httpResponse) { + if (![responseObject isKindOfClass:[NSDictionary class]]) { + NSString *message = [NSString stringWithFormat:@"Invalid response updating taxonomy of type: %@", typeIdentifier]; + [self handleResponseErrorWithMessage:message url:requestUrl failure:failure]; + return; + } + success(responseObject); + } failure:^(NSError * _Nonnull error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + + + +#pragma mark - helpers + +- (NSArray *)remoteCategoriesWithJSONArray:(NSArray *)jsonArray +{ + return [jsonArray wpkit_map:^id(NSDictionary *jsonCategory) { + return [self remoteCategoryWithJSONDictionary:jsonCategory]; + }]; +} + +- (RemotePostCategory *)remoteCategoryWithJSONDictionary:(NSDictionary *)jsonCategory +{ + RemotePostCategory *category = [RemotePostCategory new]; + category.categoryID = [jsonCategory numberForKey:TaxonomyRESTIDParameter]; + category.name = [jsonCategory stringForKey:TaxonomyRESTNameParameter]; + category.parentID = [jsonCategory numberForKey:TaxonomyRESTParentParameter] ?: @0; + return category; +} + +- (NSArray *)remoteTagsWithJSONArray:(NSArray *)jsonArray +{ + return [jsonArray wpkit_map:^id(NSDictionary *jsonTag) { + return [self remoteTagWithJSONDictionary:jsonTag]; + }]; +} + +- (RemotePostTag *)remoteTagWithJSONDictionary:(NSDictionary *)jsonTag +{ + RemotePostTag *tag = [RemotePostTag new]; + tag.tagID = [jsonTag numberForKey:TaxonomyRESTIDParameter]; + tag.name = [jsonTag stringForKey:TaxonomyRESTNameParameter]; + tag.slug = [jsonTag stringForKey:TaxonomyRESTSlugParameter]; + tag.tagDescription = [jsonTag stringForKey:TaxonomyRESTDescriptionParameter]; + tag.postCount = [jsonTag numberForKey:TaxonomyRESTPostCountParameter]; + return tag; +} + +- (NSDictionary *)parametersForPaging:(RemoteTaxonomyPaging *)paging +{ + NSMutableDictionary *dictionary = [NSMutableDictionary dictionary]; + if (paging.number) { + [dictionary setObject:paging.number forKey:TaxonomyRESTNumberParameter]; + } + if (paging.offset) { + [dictionary setObject:paging.offset forKey:TaxonomyRESTOffsetParameter]; + } + if (paging.page) { + [dictionary setObject:paging.page forKey:TaxonomyRESTPageParameter]; + } + if (paging.order == RemoteTaxonomyPagingOrderAscending) { + [dictionary setObject:@"ASC" forKey:TaxonomyRESTOrderParameter]; + } else if (paging.order == RemoteTaxonomyPagingOrderDescending) { + [dictionary setObject:@"DESC" forKey:TaxonomyRESTOrderParameter]; + } + if (paging.orderBy == RemoteTaxonomyPagingResultsOrderingByName) { + [dictionary setObject:@"name" forKey:TaxonomyRESTOrderByParameter]; + } else if (paging.orderBy == RemoteTaxonomyPagingResultsOrderingByCount) { + [dictionary setObject:@"count" forKey:TaxonomyRESTOrderByParameter]; + } + return dictionary.count ? dictionary : nil; +} + +- (void)handleResponseErrorWithMessage:(NSString *)message + url:(NSString *)urlStr + failure:(nullable void(^)(NSError *error))failure +{ + WPKitLogError(@"%@ - URL: %@", message, urlStr); + NSError *error = [NSError errorWithDomain:NSURLErrorDomain + code:NSURLErrorBadServerResponse + userInfo:@{NSLocalizedDescriptionKey: message}]; + if (failure) { + failure(error); + } +} + +@end + +NS_ASSUME_NONNULL_END diff --git a/Modules/Sources/WordPressKitObjC/TaxonomyServiceRemoteXMLRPC.m b/Modules/Sources/WordPressKitObjC/TaxonomyServiceRemoteXMLRPC.m new file mode 100644 index 000000000000..493584999c51 --- /dev/null +++ b/Modules/Sources/WordPressKitObjC/TaxonomyServiceRemoteXMLRPC.m @@ -0,0 +1,360 @@ +#import "TaxonomyServiceRemoteXMLRPC.h" +#import "RemotePostCategory.h" +#import "RemotePostTag.h" +#import "RemoteTaxonomyPaging.h" +#import "NSString+Helpers.h" +#import "WPMapFilterReduce.h" +#import "WPKitLogging.h" + +@import NSObject_SafeExpectations; + +NS_ASSUME_NONNULL_BEGIN + +static NSString * const TaxonomyXMLRPCCategoryIdentifier = @"category"; +static NSString * const TaxonomyXMLRPCTagIdentifier = @"post_tag"; + +static NSString * const TaxonomyXMLRPCIDParameter = @"term_id"; +static NSString * const TaxonomyXMLRPCSlugParameter = @"slug"; +static NSString * const TaxonomyXMLRPCNameParameter = @"name"; +static NSString * const TaxonomyXMLRPCDescriptionParameter = @"description"; +static NSString * const TaxonomyXMLRPCParentParameter = @"parent"; +static NSString * const TaxonomyXMLRPCSearchParameter = @"search"; +static NSString * const TaxonomyXMLRPCOrderParameter = @"order"; +static NSString * const TaxonomyXMLRPCOrderByParameter = @"order_by"; +static NSString * const TaxonomyXMLRPCNumberParameter = @"number"; +static NSString * const TaxonomyXMLRPCOffsetParameter = @"offset"; + + +@implementation TaxonomyServiceRemoteXMLRPC + +#pragma mark - categories + +- (void)createCategory:(RemotePostCategory *)category + success:(nullable void (^)(RemotePostCategory *))success + failure:(nullable void (^)(NSError *))failure +{ + NSMutableDictionary *extraParameters = [NSMutableDictionary dictionary]; + [extraParameters setObject:category.name ?: [NSNull null] forKey:TaxonomyXMLRPCNameParameter]; + if ([category.parentID integerValue] > 0) { + [extraParameters setObject:category.parentID forKey:TaxonomyXMLRPCParentParameter]; + } + + [self createTaxonomyWithType:TaxonomyXMLRPCCategoryIdentifier + parameters:extraParameters + success:^(NSString *responseString) { + RemotePostCategory *newCategory = [RemotePostCategory new]; + NSString *categoryID = responseString; + newCategory.categoryID = [categoryID wpkit_numericValue]; + if (success) { + success(newCategory); + } + } failure:failure]; +} + +- (void)getCategoriesWithSuccess:(void (^)(NSArray *))success + failure:(nullable void (^)(NSError *))failure +{ + [self getTaxonomiesWithType:TaxonomyXMLRPCCategoryIdentifier + parameters:nil + success:^(NSArray *responseArray) { + success([self remoteCategoriesFromXMLRPCArray:responseArray]); + } failure:failure]; +} + +- (void)getCategoriesWithPaging:(RemoteTaxonomyPaging *)paging + success:(void (^)(NSArray *categories))success + failure:(nullable void (^)(NSError *error))failure +{ + [self getTaxonomiesWithType:TaxonomyXMLRPCCategoryIdentifier + parameters:[self parametersForPaging:paging] + success:^(NSArray *responseArray) { + success([self remoteCategoriesFromXMLRPCArray:responseArray]); + } failure:failure]; +} + +- (void)searchCategoriesWithName:(NSString *)nameQuery + success:(void (^)(NSArray *))success + failure:(nullable void (^)(NSError *))failure +{ + NSDictionary *searchParameters = @{TaxonomyXMLRPCSearchParameter: nameQuery}; + [self getTaxonomiesWithType:TaxonomyXMLRPCCategoryIdentifier + parameters:searchParameters + success:^(NSArray *responseArray) { + success([self remoteCategoriesFromXMLRPCArray:responseArray]); + } failure:failure]; +} + +#pragma mark - tags + +- (void)createTag:(RemotePostTag *)tag + success:(nullable void (^)(RemotePostTag *tag))success + failure:(nullable void (^)(NSError *error))failure +{ + NSMutableDictionary *extraParameters = [NSMutableDictionary dictionary]; + [extraParameters setObject:tag.name ?: [NSNull null] forKey:TaxonomyXMLRPCNameParameter]; + [extraParameters setObject:tag.tagDescription ?: [NSNull null] forKey:TaxonomyXMLRPCDescriptionParameter]; + + [self createTaxonomyWithType:TaxonomyXMLRPCTagIdentifier + parameters:extraParameters + success:^(NSString *responseString) { + RemotePostTag *newTag = [RemotePostTag new]; + NSString *tagID = responseString; + newTag.tagID = [tagID wpkit_numericValue]; + newTag.name = tag.name; + newTag.tagDescription = tag.tagDescription; + newTag.slug = tag.slug; + if (success) { + success(newTag); + } + } failure:failure]; +} + +- (void)updateTag:(RemotePostTag *)tag + success:(nullable void (^)(RemotePostTag *tag))success + failure:(nullable void (^)(NSError *error))failure +{ + NSMutableDictionary *extraParameters = [NSMutableDictionary dictionary]; + [extraParameters setObject:tag.name ?: [NSNull null] forKey:TaxonomyXMLRPCNameParameter]; + [extraParameters setObject:tag.tagDescription ?: [NSNull null] forKey:TaxonomyXMLRPCDescriptionParameter]; + + [self editTaxonomyWithType:TaxonomyXMLRPCTagIdentifier + termId:tag.tagID + parameters:extraParameters success:^(BOOL response) { + if (success) { + success(tag); + } + } failure:failure]; +} + +- (void)deleteTag:(RemotePostTag *)tag + success:(nullable void (^)(void))success + failure:(nullable void (^)(NSError *error))failure +{ + [self deleteTaxonomyWithType:TaxonomyXMLRPCTagIdentifier + termId:tag.tagID + parameters:nil success:^(BOOL response) { + if (success) { + success(); + } + } failure:failure]; +} + +- (void)getTagsWithSuccess:(void (^)(NSArray *))success + failure:(nullable void (^)(NSError *))failure +{ + [self getTaxonomiesWithType:TaxonomyXMLRPCTagIdentifier + parameters:nil + success:^(NSArray *responseArray) { + success([self remoteTagsFromXMLRPCArray:responseArray]); + } failure:failure]; +} + +- (void)getTagsWithPaging:(RemoteTaxonomyPaging *)paging + success:(void (^)(NSArray *tags))success + failure:(nullable void (^)(NSError *error))failure +{ + [self getTaxonomiesWithType:TaxonomyXMLRPCTagIdentifier + parameters:[self parametersForPaging:paging] + success:^(NSArray *responseArray) { + success([self remoteTagsFromXMLRPCArray:responseArray]); + } failure:failure]; +} + +- (void)searchTagsWithName:(NSString *)nameQuery + success:(void (^)(NSArray *))success + failure:(nullable void (^)(NSError *))failure +{ + NSDictionary *searchParameters = @{TaxonomyXMLRPCSearchParameter: nameQuery}; + [self getTaxonomiesWithType:TaxonomyXMLRPCTagIdentifier + parameters:searchParameters + success:^(NSArray *responseArray) { + success([self remoteTagsFromXMLRPCArray:responseArray]); + } failure:failure]; +} + +#pragma mark - default methods + +- (void)createTaxonomyWithType:(NSString *)typeIdentifier + parameters:(nullable NSDictionary *)parameters + success:(void (^)(NSString *responseString))success + failure:(nullable void (^)(NSError *error))failure +{ + NSMutableDictionary *mutableParametersDict = [NSMutableDictionary dictionaryWithDictionary:@{@"taxonomy": typeIdentifier}]; + NSArray *xmlrpcParameters = nil; + if (parameters.count) { + [mutableParametersDict addEntriesFromDictionary:parameters]; + } + + xmlrpcParameters = [self XMLRPCArgumentsWithExtra:mutableParametersDict]; + + [self.api callMethod:@"wp.newTerm" + parameters:xmlrpcParameters + success:^(id responseObject, NSHTTPURLResponse *httpResponse) { + if (![responseObject respondsToSelector:@selector(wpkit_numericValue)]) { + NSString *message = [NSString stringWithFormat:@"Invalid response creating taxonomy of type: %@", typeIdentifier]; + [self handleResponseErrorWithMessage:message method:@"wp.newTerm" failure:failure]; + return; + } + success(responseObject); + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + +- (void)getTaxonomiesWithType:(NSString *)typeIdentifier + parameters:(nullable NSDictionary *)parameters + success:(void (^)(NSArray *responseArray))success + failure:(nullable void (^)(NSError *error))failure +{ + NSArray *xmlrpcParameters = nil; + if (parameters.count) { + xmlrpcParameters = [self XMLRPCArgumentsWithExtra:@[typeIdentifier, parameters]]; + }else { + xmlrpcParameters = [self XMLRPCArgumentsWithExtra:typeIdentifier]; + } + [self.api callMethod:@"wp.getTerms" + parameters:xmlrpcParameters + success:^(id responseObject, NSHTTPURLResponse *httpResponse) { + if (![responseObject isKindOfClass:[NSArray class]]) { + NSString *message = [NSString stringWithFormat:@"Invalid response requesting taxonomy of type: %@", typeIdentifier]; + [self handleResponseErrorWithMessage:message method:@"wp.getTerms" failure:failure]; + return; + } + success(responseObject); + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + +- (void)deleteTaxonomyWithType:(NSString *)typeIdentifier + termId:(NSNumber *)termId + parameters:(nullable NSDictionary *)parameters + success:(void (^)(BOOL response))success + failure:(nullable void (^)(NSError *error))failure +{ + NSArray *xmlrpcParameters = [self XMLRPCArgumentsWithExtraDefaults:@[typeIdentifier, termId] + andExtra:nil]; + + [self.api callMethod:@"wp.deleteTerm" + parameters:xmlrpcParameters + success:^(id responseObject, NSHTTPURLResponse *httpResponse) { + if (![responseObject respondsToSelector:@selector(boolValue)]) { + NSString *message = [NSString stringWithFormat:@"Invalid response deleting taxonomy of type: %@", typeIdentifier]; + [self handleResponseErrorWithMessage:message method:@"wp.deleteTerm" failure:failure]; + return; + } + success([responseObject boolValue]); + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + +- (void)editTaxonomyWithType:(NSString *)typeIdentifier + termId:(NSNumber *)termId + parameters:(nullable NSDictionary *)parameters + success:(void (^)(BOOL response))success + failure:(nullable void (^)(NSError *error))failure +{ + NSMutableDictionary *mutableParametersDict = [NSMutableDictionary dictionaryWithDictionary:@{@"taxonomy": typeIdentifier}]; + NSArray *xmlrpcParameters = nil; + if (parameters.count) { + [mutableParametersDict addEntriesFromDictionary:parameters]; + } + + xmlrpcParameters = [self XMLRPCArgumentsWithExtraDefaults:@[termId] andExtra:mutableParametersDict]; + + [self.api callMethod:@"wp.editTerm" + parameters:xmlrpcParameters + success:^(id responseObject, NSHTTPURLResponse *httpResponse) { + if (![responseObject respondsToSelector:@selector(boolValue)]) { + NSString *message = [NSString stringWithFormat:@"Invalid response editing taxonomy of type: %@", typeIdentifier]; + [self handleResponseErrorWithMessage:message method:@"wp.editTerm" failure:failure]; + return; + } + success([responseObject boolValue]); + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + +#pragma mark - helpers + +- (NSArray *)remoteCategoriesFromXMLRPCArray:(NSArray *)xmlrpcArray +{ + return [xmlrpcArray wpkit_map:^id(NSDictionary *xmlrpcCategory) { + return [self remoteCategoryFromXMLRPCDictionary:xmlrpcCategory]; + }]; +} + +- (RemotePostCategory *)remoteCategoryFromXMLRPCDictionary:(NSDictionary *)xmlrpcDictionary +{ + RemotePostCategory *category = [RemotePostCategory new]; + category.categoryID = [xmlrpcDictionary numberForKey:TaxonomyXMLRPCIDParameter]; + category.name = [xmlrpcDictionary stringForKey:TaxonomyXMLRPCNameParameter]; + category.parentID = [xmlrpcDictionary numberForKey:TaxonomyXMLRPCParentParameter]; + return category; +} + +- (NSArray *)remoteTagsFromXMLRPCArray:(NSArray *)xmlrpcArray +{ + return [xmlrpcArray wpkit_map:^id(NSDictionary *xmlrpcTag) { + return [self remoteTagFromXMLRPCDictionary:xmlrpcTag]; + }]; +} + +- (RemotePostTag *)remoteTagFromXMLRPCDictionary:(NSDictionary *)xmlrpcDictionary +{ + RemotePostTag *tag = [RemotePostTag new]; + tag.tagID = [xmlrpcDictionary numberForKey:TaxonomyXMLRPCIDParameter]; + tag.name = [xmlrpcDictionary stringForKey:TaxonomyXMLRPCNameParameter]; + tag.slug = [xmlrpcDictionary stringForKey:TaxonomyXMLRPCSlugParameter]; + tag.tagDescription = [xmlrpcDictionary stringForKey:TaxonomyXMLRPCDescriptionParameter]; + return tag; +} + +- (NSDictionary *)parametersForPaging:(RemoteTaxonomyPaging *)paging +{ + NSMutableDictionary *dictionary = [NSMutableDictionary dictionary]; + if (paging.number) { + [dictionary setObject:paging.number forKey:TaxonomyXMLRPCNumberParameter]; + } + if (paging.offset) { + [dictionary setObject:paging.offset forKey:TaxonomyXMLRPCOffsetParameter]; + } + if (paging.order == RemoteTaxonomyPagingOrderAscending) { + [dictionary setObject:@"ASC" forKey:TaxonomyXMLRPCOrderParameter]; + } else if (paging.order == RemoteTaxonomyPagingOrderDescending) { + [dictionary setObject:@"DESC" forKey:TaxonomyXMLRPCOrderParameter]; + } + if (paging.orderBy == RemoteTaxonomyPagingResultsOrderingByName) { + [dictionary setObject:@"name" forKey:TaxonomyXMLRPCOrderByParameter]; + } else if (paging.orderBy == RemoteTaxonomyPagingResultsOrderingByCount) { + [dictionary setObject:@"count" forKey:TaxonomyXMLRPCOrderByParameter]; + } + return dictionary.count ? dictionary : nil; +} + +- (void)handleResponseErrorWithMessage:(NSString *)message + method:(NSString *)methodStr + failure:(nullable void(^)(NSError *error))failure +{ + WPKitLogError(@"%@ - method: %@", message, methodStr); + NSError *error = [NSError errorWithDomain:NSURLErrorDomain + code:NSURLErrorBadServerResponse + userInfo:@{NSLocalizedDescriptionKey: message}]; + if (failure) { + failure(error); + } +} + +@end + +NS_ASSUME_NONNULL_END diff --git a/Modules/Sources/WordPressKitObjC/ThemeServiceRemote.m b/Modules/Sources/WordPressKitObjC/ThemeServiceRemote.m new file mode 100644 index 000000000000..e20c31f4d741 --- /dev/null +++ b/Modules/Sources/WordPressKitObjC/ThemeServiceRemote.m @@ -0,0 +1,471 @@ +#import "ThemeServiceRemote.h" + +#import "RemoteTheme.h" +@import NSObject_SafeExpectations; + +// Service dictionary keys +static NSString* const ThemeServiceRemoteThemesKey = @"themes"; +static NSString* const ThemeServiceRemoteThemeCountKey = @"found"; +static NSString* const ThemeRequestTierKey = @"tier"; +static NSString* const ThemeRequestTierAllValue = @"all"; +static NSString* const ThemeRequestTierFreeValue = @"free"; +static NSString* const ThemeRequestNumberKey = @"number"; +static NSInteger const ThemeRequestNumberValue = 50; +static NSString* const ThemeRequestPageKey = @"page"; +static NSString* const ThemeRequestSearchKey = @"search"; + +@implementation ThemeServiceRemote + +#pragma mark - Getting themes + +- (NSProgress *)getActiveThemeForBlogId:(NSNumber *)blogId + success:(ThemeServiceRemoteThemeRequestSuccessBlock)success + failure:(ThemeServiceRemoteFailureBlock)failure +{ + NSParameterAssert([blogId isKindOfClass:[NSNumber class]]); + + NSString *path = [NSString stringWithFormat:@"sites/%@/themes/mine", blogId]; + NSString *requestUrl = [self pathForEndpoint:path + withVersion:WordPressComRESTAPIVersion_1_1]; + + NSProgress *progress = [self.wordPressComRESTAPI get:requestUrl + parameters:nil + success:^(NSDictionary *themeDictionary, NSHTTPURLResponse *httpResponse) { + if (success) { + RemoteTheme *theme = [self themeFromDictionary:themeDictionary]; + theme.active = YES; + success(theme); + } + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; + + return progress; +} + +- (NSProgress *)getPurchasedThemesForBlogId:(NSNumber *)blogId + success:(ThemeServiceRemoteThemeIdentifiersRequestSuccessBlock)success + failure:(ThemeServiceRemoteFailureBlock)failure +{ + NSParameterAssert([blogId isKindOfClass:[NSNumber class]]); + + NSString *path = [NSString stringWithFormat:@"sites/%@/themes/purchased", blogId]; + NSString *requestUrl = [self pathForEndpoint:path + withVersion:WordPressComRESTAPIVersion_1_1]; + + NSProgress *progress = [self.wordPressComRESTAPI get:requestUrl + parameters:nil + success:^(NSDictionary *response, NSHTTPURLResponse *httpResponse) { + if (success) { + NSArray *themes = [self themeIdentifiersFromPurchasedThemesRequestResponse:response]; + success(themes); + } + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; + + return progress; +} + +- (NSProgress *)getThemeId:(NSString*)themeId + success:(ThemeServiceRemoteThemeRequestSuccessBlock)success + failure:(ThemeServiceRemoteFailureBlock)failure +{ + NSParameterAssert([themeId isKindOfClass:[NSString class]]); + + NSString *path = [NSString stringWithFormat:@"themes/%@", themeId]; + NSString *requestUrl = [self pathForEndpoint:path + withVersion:WordPressComRESTAPIVersion_1_1]; + + NSProgress *progress = [self.wordPressComRESTAPI get:requestUrl + parameters:nil + success:^(NSDictionary *themeDictionary, NSHTTPURLResponse *httpResponse) { + if (success) { + RemoteTheme *theme = [self themeFromDictionary:themeDictionary]; + success(theme); + } + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; + + return progress; +} + +- (NSProgress *)getWPThemesPage:(NSInteger)page + search:(NSString *)search + freeOnly:(BOOL)freeOnly + success:(ThemeServiceRemoteThemesRequestSuccessBlock)success + failure:(ThemeServiceRemoteFailureBlock)failure +{ + NSParameterAssert(page > 0); + + NSString *requestUrl = [self pathForEndpoint:@"themes" + withVersion:WordPressComRESTAPIVersion_2_0]; + + NSMutableDictionary *parameters = [@{ + ThemeRequestTierKey: freeOnly ? ThemeRequestTierFreeValue : ThemeRequestTierAllValue, + ThemeRequestNumberKey: @(ThemeRequestNumberValue), + ThemeRequestPageKey: @(page) + } mutableCopy]; + + if (search) { + parameters[ThemeRequestSearchKey] = search; + } + + return [self getThemesWithRequestUrl:requestUrl + page:page + parameters:parameters + success:success + failure:failure]; +} + +- (NSProgress *)getThemesPage:(NSInteger)page + path:(NSString *)path + success:(ThemeServiceRemoteThemesRequestSuccessBlock)success + failure:(ThemeServiceRemoteFailureBlock)failure +{ + NSParameterAssert(page > 0); + NSParameterAssert([path isKindOfClass:[NSString class]]); + + NSString *requestUrl = [self pathForEndpoint:path + withVersion:WordPressComRESTAPIVersion_1_2]; + + NSDictionary *parameters = @{ThemeRequestTierKey: ThemeRequestTierAllValue, + ThemeRequestNumberKey: @(ThemeRequestNumberValue), + ThemeRequestPageKey: @(page), + }; + + return [self getThemesWithRequestUrl:requestUrl + page:page + parameters:parameters + success:success + failure:failure]; +} + +- (NSProgress *)getThemesForBlogId:(NSNumber *)blogId + page:(NSInteger)page + success:(ThemeServiceRemoteThemesRequestSuccessBlock)success + failure:(ThemeServiceRemoteFailureBlock)failure +{ + NSParameterAssert([blogId isKindOfClass:[NSNumber class]]); + NSParameterAssert(page > 0); + + NSProgress *progress = [self getThemesForBlogId:blogId + page:page + apiVersion:WordPressComRESTAPIVersion_1_2 + params:@{ThemeRequestTierKey: ThemeRequestTierAllValue} + success:success + failure:failure]; + + return progress; +} + +- (NSProgress *)getCustomThemesForBlogId:(NSNumber *)blogId + success:(ThemeServiceRemoteThemesRequestSuccessBlock)success + failure:(ThemeServiceRemoteFailureBlock)failure +{ + + NSParameterAssert([blogId isKindOfClass:[NSNumber class]]); + + NSProgress *progress = [self getThemesForBlogId:blogId + page:1 + apiVersion:WordPressComRESTAPIVersion_1_0 + params:@{} + success:success + failure:failure]; + + return progress; +} + +- (NSProgress *)getThemesForBlogId:(NSNumber *)blogId + page:(NSInteger)page + apiVersion:(WordPressComRESTAPIVersion) apiVersion + params:(NSDictionary *)params + success:(ThemeServiceRemoteThemesRequestSuccessBlock)success + failure:(ThemeServiceRemoteFailureBlock)failure +{ + + NSParameterAssert(page > 0); + + NSString *path = [NSString stringWithFormat:@"sites/%@/themes", blogId]; + NSString *requestUrl = [self pathForEndpoint:path + withVersion:apiVersion]; + + NSMutableDictionary *parameters = [params mutableCopy]; + parameters[ThemeRequestNumberKey] = @(ThemeRequestNumberValue); + parameters[ThemeRequestPageKey] = @(page); + + return [self getThemesWithRequestUrl:requestUrl + page:page + parameters:parameters + success:success + failure:failure]; +} + +- (void)getStartingThemesForCategory:(NSString *)category + page:(NSInteger)page + success:(ThemeServiceRemoteThemesRequestSuccessBlock)success + failure:(ThemeServiceRemoteFailureBlock)failure +{ + NSParameterAssert(page > 0); + NSParameterAssert([category isKindOfClass:[NSString class]]); + + NSString *path = [NSString stringWithFormat:@"themes/?filter=starting-%@", category]; + NSString *requestUrl = [self pathForEndpoint:path + withVersion:WordPressComRESTAPIVersion_1_2]; + + NSDictionary *parameters = @{ + ThemeRequestNumberKey: @(ThemeRequestNumberValue), + ThemeRequestPageKey: @(page), + }; + + [self getThemesWithRequestUrl:requestUrl + page:page + parameters:parameters + success:success + failure:failure]; +} + +- (NSProgress *)getThemesWithRequestUrl:(NSString *)requestUrl + page:(NSInteger)page + parameters:(NSDictionary *)parameters + success:(ThemeServiceRemoteThemesRequestSuccessBlock)success + failure:(ThemeServiceRemoteFailureBlock)failure +{ + + return [self.wordPressComRESTAPI get:requestUrl + parameters:parameters + success:^(NSDictionary *response, NSHTTPURLResponse *httpResponse) { + if (success) { + NSArray *themes = [self themesFromMultipleThemesRequestResponse:response]; + NSInteger themesLoaded = (page - 1) * ThemeRequestNumberValue; + for (RemoteTheme *theme in themes){ + theme.order = ++themesLoaded; + } + // v1 of the API does not return the found field + NSInteger themesCount = MAX(themes.count, [[response numberForKey:ThemeServiceRemoteThemeCountKey] integerValue]); + BOOL hasMore = themesLoaded < themesCount; + success(themes, hasMore, themesCount); + } + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + +#pragma mark - Activating themes + +- (NSProgress *)activateThemeId:(NSString *)themeId + forBlogId:(NSNumber *)blogId + success:(ThemeServiceRemoteThemeRequestSuccessBlock)success + failure:(ThemeServiceRemoteFailureBlock)failure +{ + NSParameterAssert([themeId isKindOfClass:[NSString class]]); + NSParameterAssert([blogId isKindOfClass:[NSNumber class]]); + + NSString* const path = [NSString stringWithFormat:@"sites/%@/themes/mine", blogId]; + NSString *requestUrl = [self pathForEndpoint:path + withVersion:WordPressComRESTAPIVersion_1_1]; + + NSDictionary* parameters = @{@"theme": themeId}; + + NSProgress *progress = [self.wordPressComRESTAPI post:requestUrl + parameters:parameters + success:^(NSDictionary *themeDictionary, NSHTTPURLResponse *httpResponse) { + if (success) { + RemoteTheme *theme = [self themeFromDictionary:themeDictionary]; + theme.active = YES; + success(theme); + } + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; + + return progress; +} + +- (NSProgress *)installThemeId:(NSString*)themeId + forBlogId:(NSNumber *)blogId + success:(ThemeServiceRemoteThemeRequestSuccessBlock)success + failure:(ThemeServiceRemoteFailureBlock)failure +{ + NSParameterAssert([themeId isKindOfClass:[NSString class]]); + NSParameterAssert([blogId isKindOfClass:[NSNumber class]]); + + NSString* const path = [NSString stringWithFormat:@"sites/%@/themes/%@/install", blogId, themeId]; + NSString *requestUrl = [self pathForEndpoint:path + withVersion:WordPressComRESTAPIVersion_1_1]; + + NSProgress *progress = [self.wordPressComRESTAPI post:requestUrl + parameters:nil + success:^(NSDictionary *themeDictionary, NSHTTPURLResponse *httpResponse) { + if (success) { + RemoteTheme *theme = [self themeFromDictionary:themeDictionary]; + success(theme); + } + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; + return progress; +} + +#pragma mark - Parsing responses + +/** + * @brief Parses a purchased-themes-request response. + * + * @param response The response object. Cannot be nil. + */ +- (NSArray *)themeIdentifiersFromPurchasedThemesRequestResponse:(id)response +{ + NSParameterAssert(response != nil); + + NSArray *themeIdentifiers = [response arrayForKey:ThemeServiceRemoteThemesKey]; + + return themeIdentifiers; +} + +/** + * @brief Parses a generic multi-themes-request response. + * + * @param response The response object. Cannot be nil. + */ +- (NSArray *)themesFromMultipleThemesRequestResponse:(id)response +{ + NSParameterAssert(response != nil); + + NSArray *themeDictionaries = [response arrayForKey:ThemeServiceRemoteThemesKey]; + NSArray *themes = [self themesFromDictionaries:themeDictionaries]; + + return themes; +} + +#pragma mark - Parsing the dictionary replies + +/** + * @brief Creates a remote theme object from the specified dictionary. + * + * @param dictionary The dictionary containing the theme information. Cannot be nil. + * + * @returns A remote theme object. + */ +- (RemoteTheme *)themeFromDictionary:(NSDictionary *)dictionary +{ + NSParameterAssert([dictionary isKindOfClass:[NSDictionary class]]); + + static NSString* const ThemeActiveKey = @"active"; + static NSString* const ThemeTypeKey = @"theme_type"; + static NSString* const ThemeAuthorKey = @"author"; + static NSString* const ThemeAuthorURLKey = @"author_uri"; + static NSString* const ThemeCostPath = @"cost.number"; + static NSString* const ThemeDemoURLKey = @"demo_uri"; + static NSString* const ThemeURL = @"theme_uri"; + static NSString* const ThemeDescriptionKey = @"description"; + static NSString* const ThemeDownloadURLKey = @"download_uri"; + static NSString* const ThemeIdKey = @"id"; + static NSString* const ThemeNameKey = @"name"; + static NSString* const ThemePreviewURLKey = @"preview_url"; + static NSString* const ThemePriceKey = @"price"; + static NSString* const ThemePurchasedKey = @"purchased"; + static NSString* const ThemePopularityRankKey = @"rank_popularity"; + static NSString* const ThemeScreenshotKey = @"screenshot"; + static NSString* const ThemeStylesheetKey = @"stylesheet"; + static NSString* const ThemeTrendingRankKey = @"rank_trending"; + static NSString* const ThemeVersionKey = @"version"; + static NSString* const ThemeDomainPublic = @"pub"; + static NSString* const ThemeDomainPremium = @"premium"; + + RemoteTheme *theme = [RemoteTheme new]; + + [self loadLaunchDateForTheme:theme fromDictionary:dictionary]; + + theme.active = [[dictionary numberForKey:ThemeActiveKey] boolValue]; + theme.type = [dictionary stringForKey:ThemeTypeKey]; + theme.author = [dictionary stringForKey:ThemeAuthorKey]; + theme.authorUrl = [dictionary stringForKey:ThemeAuthorURLKey]; + theme.demoUrl = [dictionary stringForKey:ThemeDemoURLKey]; + theme.themeUrl = [dictionary stringForKey:ThemeURL]; + theme.desc = [dictionary stringForKey:ThemeDescriptionKey]; + theme.downloadUrl = [dictionary stringForKey:ThemeDownloadURLKey]; + theme.name = [dictionary stringForKey:ThemeNameKey]; + theme.popularityRank = [dictionary numberForKey:ThemePopularityRankKey]; + theme.previewUrl = [dictionary stringForKey:ThemePreviewURLKey]; + theme.price = [dictionary stringForKey:ThemePriceKey]; + theme.purchased = [dictionary numberForKey:ThemePurchasedKey]; + theme.screenshotUrl = [dictionary stringForKey:ThemeScreenshotKey]; + theme.stylesheet = [dictionary stringForKey:ThemeStylesheetKey]; + theme.themeId = [dictionary stringForKey:ThemeIdKey]; + theme.trendingRank = [dictionary numberForKey:ThemeTrendingRankKey]; + theme.version = [dictionary stringForKey:ThemeVersionKey]; + + if (!theme.stylesheet) { + NSString *domain = [dictionary numberForKeyPath:ThemeCostPath].intValue > 0 ? ThemeDomainPremium : ThemeDomainPublic; + theme.stylesheet = [NSString stringWithFormat:@"%@/%@", domain, theme.themeId]; + } + + return theme; +} + +/** + * @brief Creates remote theme objects from the specified array of dictionaries. + * + * @param dictionaries The array of dictionaries containing the themes information. Cannot + * be nil. + * + * @returns An array of remote theme objects. + */ +- (NSArray *)themesFromDictionaries:(NSArray *)dictionaries +{ + NSParameterAssert([dictionaries isKindOfClass:[NSArray class]]); + + NSMutableArray *themes = [[NSMutableArray alloc] initWithCapacity:dictionaries.count]; + + for (NSDictionary *dictionary in dictionaries) { + NSAssert([dictionary isKindOfClass:[NSDictionary class]], + @"Expected a dictionary."); + + RemoteTheme *theme = [self themeFromDictionary:dictionary]; + + [themes addObject:theme]; + } + + return [NSArray arrayWithArray:themes]; +} + +#pragma mark - Field parsing + +/** + * @brief Loads a theme's launch date from a dictionary into the specified remote theme + * object. + * + * @param theme The theme to load the info into. Cannot be nil. + * @param dictionary The dictionary to load the info from. Cannot be nil. + */ +- (void)loadLaunchDateForTheme:(RemoteTheme *)theme + fromDictionary:(NSDictionary *)dictionary +{ + NSParameterAssert([theme isKindOfClass:[RemoteTheme class]]); + NSParameterAssert([dictionary isKindOfClass:[NSDictionary class]]); + + static NSString* const ThemeLaunchDateKey = @"date_launched"; + + NSString *launchDateString = [dictionary stringForKey:ThemeLaunchDateKey]; + + NSDateFormatter *formatter = [[NSDateFormatter alloc] init]; + [formatter setDateFormat:@"yyyy-mm-dd"]; + + theme.launchDate = [formatter dateFromString:launchDateString]; +} + +@end diff --git a/Modules/Sources/WordPressKitObjC/WPKitDateUtils.m b/Modules/Sources/WordPressKitObjC/WPKitDateUtils.m new file mode 100644 index 000000000000..02f561cf0501 --- /dev/null +++ b/Modules/Sources/WordPressKitObjC/WPKitDateUtils.m @@ -0,0 +1,37 @@ +#import "WPKitDateUtils.h" + +@implementation WPKitDateUtils + ++ (NSDate *)dateFromISOString:(NSString *)dateString +{ + NSArray *formats = @[@"yyyy-MM-dd'T'HH:mm:ssZZZZZ", @"yyyy-MM-dd HH:mm:ss"]; + NSDate *date = nil; + if ([dateString length] == 25) { + NSRange rng = [dateString rangeOfString:@":" options:NSBackwardsSearch range:NSMakeRange(20, 5)]; + if (rng.location != NSNotFound) { + dateString = [dateString stringByReplacingCharactersInRange:rng withString:@""]; + } + } + NSDateFormatter *dateFormatter = [[NSDateFormatter alloc] init]; + dateFormatter.locale = [NSLocale localeWithLocaleIdentifier:@"en_US_POSIX"]; + dateFormatter.timeZone = [NSTimeZone timeZoneWithName:@"GMT"]; + for (NSString *dateFormat in formats) { + [dateFormatter setDateFormat:dateFormat]; + date = [dateFormatter dateFromString:dateString]; + if (date){ + return date; + } + } + return date; +} + ++ (NSString *)isoStringFromDate:(NSDate *)date +{ + NSDateFormatter *dateFormatter = [[NSDateFormatter alloc] init]; + dateFormatter.locale = [NSLocale localeWithLocaleIdentifier:@"en_US_POSIX"]; + dateFormatter.timeZone = [NSTimeZone timeZoneWithName:@"GMT"]; + [dateFormatter setDateFormat:@"yyyy-MM-dd'T'HH:mm:ssZZZZZ"]; + return [dateFormatter stringFromDate:date]; +} + +@end diff --git a/Modules/Sources/WordPressKitObjC/WPKitLogging.m b/Modules/Sources/WordPressKitObjC/WPKitLogging.m new file mode 100644 index 000000000000..d8a0607c9383 --- /dev/null +++ b/Modules/Sources/WordPressKitObjC/WPKitLogging.m @@ -0,0 +1,49 @@ +#import "WPKitLogging.h" + +static id wordPressKitLogger = nil; + +id _Nullable WPKitGetLoggingDelegate(void) +{ + return wordPressKitLogger; +} + +void WPKitSetLoggingDelegate(id _Nullable logger) +{ + wordPressKitLogger = logger; +} + +#define WPKitLogv(logFunc) \ + ({ \ + id logger = WPKitGetLoggingDelegate(); \ + if (logger == NULL) { \ + NSLog(@"[WordPressKit] Warning: please call `WPKitSetLoggingDelegate` to set a error logger."); \ + return; \ + } \ + if (![logger respondsToSelector:@selector(logFunc)]) { \ + NSLog(@"[WordPressKit] Warning: %@ does not implement " #logFunc, logger); \ + return; \ + } \ + /* Originally `performSelector:withObject:` was used to call the logging function, but for unknown reason */ \ + /* it causes a crash on `objc_retain`. So I have to switch to this strange "syntax" to call the logging function directly. */ \ + [logger logFunc [[NSString alloc] initWithFormat:str arguments:args]]; \ + }) + +#define WPKitLog(logFunc) \ + ({ \ + va_list args; \ + va_start(args, str); \ + WPKitLogv(logFunc); \ + va_end(args); \ + }) + +void WPKitLogError(NSString *str, ...) { WPKitLog(logError:); } +void WPKitLogWarning(NSString *str, ...) { WPKitLog(logWarning:); } +void WPKitLogInfo(NSString *str, ...) { WPKitLog(logInfo:); } +void WPKitLogDebug(NSString *str, ...) { WPKitLog(logDebug:); } +void WPKitLogVerbose(NSString *str, ...) { WPKitLog(logVerbose:); } + +void WPKitLogvError(NSString *str, va_list args) { WPKitLogv(logError:); } +void WPKitLogvWarning(NSString *str, va_list args) { WPKitLogv(logWarning:); } +void WPKitLogvInfo(NSString *str, va_list args) { WPKitLogv(logInfo:); } +void WPKitLogvDebug(NSString *str, va_list args) { WPKitLogv(logDebug:); } +void WPKitLogvVerbose(NSString *str, va_list args) { WPKitLogv(logVerbose:); } diff --git a/Modules/Sources/WordPressKitObjC/WPMapFilterReduce.m b/Modules/Sources/WordPressKitObjC/WPMapFilterReduce.m new file mode 100644 index 000000000000..1e8b768391d7 --- /dev/null +++ b/Modules/Sources/WordPressKitObjC/WPMapFilterReduce.m @@ -0,0 +1,28 @@ +#import "WPMapFilterReduce.h" + +@implementation NSArray (WPKitMapFilterReduce) + +- (NSArray *)wpkit_map:(WPKitMapBlock)mapBlock +{ + NSMutableArray *results = [NSMutableArray arrayWithCapacity:self.count]; + for (id obj in self) { + id objectToAdd = mapBlock(obj); + if (objectToAdd) { + [results addObject:objectToAdd]; + } + } + return [NSArray arrayWithArray:results]; +} + +- (NSArray *)wpkit_filter:(WPKitFilterBlock)filterBlock +{ + NSMutableArray *results = [NSMutableArray arrayWithCapacity:self.count]; + for (id obj in self) { + if (filterBlock(obj)) { + [results addObject:obj]; + } + } + return [NSArray arrayWithArray:results]; +} + +@end diff --git a/Modules/Sources/WordPressKitObjC/WordPressComRESTAPIVersionedPathBuilder.m b/Modules/Sources/WordPressKitObjC/WordPressComRESTAPIVersionedPathBuilder.m new file mode 100644 index 000000000000..073dcc0e0bb6 --- /dev/null +++ b/Modules/Sources/WordPressKitObjC/WordPressComRESTAPIVersionedPathBuilder.m @@ -0,0 +1,60 @@ +#import +#if SWIFT_PACKAGE +#import "WordPressComRESTAPIVersionedPathBuilder.h" +#import "WordPressComRESTAPIVersion.h" +#else +#import "WordPressKit/WordPressComRESTAPIVersionedPathBuilder.h" +#endif + +static NSString* const WordPressComRESTApiVersionStringInvalid = @"invalid_api_version"; +static NSString* const WordPressComRESTApiVersionString_1_0 = @"rest/v1"; +static NSString* const WordPressComRESTApiVersionString_1_1 = @"rest/v1.1"; +static NSString* const WordPressComRESTApiVersionString_1_2 = @"rest/v1.2"; +static NSString* const WordPressComRESTApiVersionString_1_3 = @"rest/v1.3"; +static NSString* const WordPressComRESTApiVersionString_2_0 = @"wpcom/v2"; + +@implementation WordPressComRESTAPIVersionedPathBuilder + ++ (NSString *)pathForEndpoint:(NSString *)endpoint + withVersion:(WordPressComRESTAPIVersion)apiVersion +{ + NSString *apiVersionString = [self apiVersionStringWithEnumValue:apiVersion]; + + return [NSString stringWithFormat:@"%@/%@", apiVersionString, endpoint]; +} + ++ (NSString *)apiVersionStringWithEnumValue:(WordPressComRESTAPIVersion)apiVersion +{ + NSString *result = nil; + + switch (apiVersion) { + case WordPressComRESTAPIVersion_1_0: + result = WordPressComRESTApiVersionString_1_0; + break; + + case WordPressComRESTAPIVersion_1_1: + result = WordPressComRESTApiVersionString_1_1; + break; + + case WordPressComRESTAPIVersion_1_2: + result = WordPressComRESTApiVersionString_1_2; + break; + + case WordPressComRESTAPIVersion_1_3: + result = WordPressComRESTApiVersionString_1_3; + break; + + case WordPressComRESTAPIVersion_2_0: + result = WordPressComRESTApiVersionString_2_0; + break; + + default: + NSAssert(NO, @"This should never by executed"); + result = WordPressComRESTApiVersionStringInvalid; + break; + } + + return result; +} + +@end diff --git a/Modules/Sources/WordPressKitObjC/WordPressComServiceRemote.m b/Modules/Sources/WordPressKitObjC/WordPressComServiceRemote.m new file mode 100644 index 000000000000..a7a63fdd5247 --- /dev/null +++ b/Modules/Sources/WordPressKitObjC/WordPressComServiceRemote.m @@ -0,0 +1,334 @@ +#import "WordPressComServiceRemote.h" +#import "NSString+Helpers.h" +#import "WordPressComRestApiErrorDomain.h" + +@import NSObject_SafeExpectations; + +@implementation WordPressComServiceRemote + +- (void)createWPComAccountWithEmail:(NSString *)email + andUsername:(NSString *)username + andPassword:(NSString *)password + andClientID:(NSString *)clientID + andClientSecret:(NSString *)clientSecret + success:(WordPressComServiceSuccessBlock)success + failure:(WordPressComServiceFailureBlock)failure +{ + NSParameterAssert([email isKindOfClass:[NSString class]]); + NSParameterAssert([username isKindOfClass:[NSString class]]); + NSParameterAssert([password isKindOfClass:[NSString class]]); + + [self createWPComAccountWithEmail:email + andUsername:username + andPassword:password + andClientID:clientID + andClientSecret:clientSecret + validate:NO + success:success + failure:failure]; +} + +- (void)createWPComAccountWithEmail:(NSString *)email + andUsername:(NSString *)username + andPassword:(NSString *)password + andClientID:(NSString *)clientID + andClientSecret:(NSString *)clientSecret + validate:(BOOL)validate + success:(WordPressComServiceSuccessBlock)success + failure:(WordPressComServiceFailureBlock)failure +{ + NSParameterAssert([email isKindOfClass:[NSString class]]); + NSParameterAssert([username isKindOfClass:[NSString class]]); + NSParameterAssert([password isKindOfClass:[NSString class]]); + + void (^successBlock)(id, NSHTTPURLResponse *) = ^(id responseObject, NSHTTPURLResponse *httpResponse) { + success(responseObject); + }; + + void (^failureBlock)(NSError *, NSHTTPURLResponse *) = ^(NSError *error, NSHTTPURLResponse *httpResponse){ + NSError *errorWithLocalizedMessage = [self errorWithLocalizedMessage:error]; + failure(errorWithLocalizedMessage); + }; + + NSDictionary *params = @{ + @"email": email, + @"username": username, + @"password": password, + @"validate": @(validate), + @"client_id": clientID, + @"client_secret": clientSecret + }; + + NSString *requestUrl = [self pathForEndpoint:@"users/new" + withVersion:WordPressComRESTAPIVersion_1_1]; + + [self.wordPressComRESTAPI post:requestUrl parameters:params success:successBlock failure:failureBlock]; +} + +- (void)createWPComAccountWithGoogle:(NSString *)token + andClientID:(NSString *)clientID + andClientSecret:(NSString *)clientSecret + success:(WordPressComServiceSuccessBlock)success + failure:(WordPressComServiceFailureBlock)failure +{ + NSDictionary *params = @{ + @"client_id": clientID, + @"client_secret": clientSecret, + @"id_token": token, + @"service": @"google", + @"signup_flow_name": @"social", + }; + + [self createSocialWPComAccountWithParams:params success:success failure:failure]; +} + +- (void)createWPComAccountWithApple:(NSString *)token + andEmail:(NSString *)email + andFullName:(NSString *)fullName + andClientID:(NSString *)clientID + andClientSecret:(NSString *)clientSecret + success:(WordPressComServiceSuccessBlock)success + failure:(WordPressComServiceFailureBlock)failure +{ + NSDictionary *params = @{ + @"client_id": clientID, + @"client_secret": clientSecret, + @"id_token": token, + @"service": @"apple", + @"signup_flow_name": @"social", + @"user_email": email, + @"user_name": fullName, + }; + + [self createSocialWPComAccountWithParams:params success:success failure:failure]; +} + +- (void)createSocialWPComAccountWithParams:(NSDictionary *)params + success:(WordPressComServiceSuccessBlock)success + failure:(WordPressComServiceFailureBlock)failure +{ + void (^successBlock)(id, NSHTTPURLResponse *) = ^(id responseObject, NSHTTPURLResponse *httpResponse) { + success(responseObject); + }; + + void (^failureBlock)(NSError *, NSHTTPURLResponse *) = ^(NSError *error, NSHTTPURLResponse *httpResponse){ + NSError *errorWithLocalizedMessage = [self errorWithLocalizedMessage:error]; + failure(errorWithLocalizedMessage); + }; + + NSString *requestUrl = [self pathForEndpoint:@"users/social/new" withVersion:WordPressComRESTAPIVersion_1_0]; + [self.wordPressComRESTAPI post:requestUrl parameters:params success:successBlock failure:failureBlock]; +} + +- (void)validateWPComBlogWithUrl:(NSString *)blogUrl + andBlogTitle:(NSString *)blogTitle + andLanguageId:(NSString *)languageId + andClientID:(NSString *)clientID + andClientSecret:(NSString *)clientSecret + success:(WordPressComServiceSuccessBlock)success + failure:(WordPressComServiceFailureBlock)failure +{ + [self createWPComBlogWithUrl:blogUrl + andBlogTitle:blogTitle + andLanguageId:languageId + andBlogVisibility:WordPressComServiceBlogVisibilityPublic + andClientID:clientID + andClientSecret:clientSecret + validate:YES + success:success + failure:failure]; +} + +- (void)createWPComBlogWithUrl:(NSString *)blogUrl + andBlogTitle:(NSString *)blogTitle + andLanguageId:(NSString *)languageId + andBlogVisibility:(WordPressComServiceBlogVisibility)visibility + andClientID:(NSString *)clientID + andClientSecret:(NSString *)clientSecret + success:(WordPressComServiceSuccessBlock)success + failure:(WordPressComServiceFailureBlock)failure +{ + [self createWPComBlogWithUrl:blogUrl + andBlogTitle:blogTitle + andLanguageId:languageId + andBlogVisibility:visibility + andClientID:clientID + andClientSecret:clientSecret + validate:NO + success:success + failure:failure]; +} + +- (void)createWPComBlogWithUrl:(NSString *)blogUrl + andBlogTitle:(NSString *)blogTitle + andLanguageId:(NSString *)languageId + andBlogVisibility:(WordPressComServiceBlogVisibility)visibility + andClientID:(NSString *)clientID + andClientSecret:(NSString *)clientSecret + validate:(BOOL)validate + success:(WordPressComServiceSuccessBlock)success + failure:(WordPressComServiceFailureBlock)failure +{ + NSParameterAssert([blogUrl isKindOfClass:[NSString class]]); + NSParameterAssert([languageId isKindOfClass:[NSString class]]); + + void (^successBlock)(id, NSHTTPURLResponse *) = ^(id responseObject, NSHTTPURLResponse *httpResponse) { + NSDictionary *response = responseObject; + if ([response count] == 0) { + failure([self.wordPressComRESTAPI unknownResponseError]); + } else { + success(responseObject); + } + }; + + void (^failureBlock)(NSError *, NSHTTPURLResponse *) = ^(NSError *error, NSHTTPURLResponse *httpResponse){ + NSError *errorWithLocalizedMessage = [self errorWithLocalizedMessage:error]; + failure(errorWithLocalizedMessage); + }; + + if (blogTitle == nil) { + blogTitle = @""; + } + + int blogVisibility = 1; + if (visibility == WordPressComServiceBlogVisibilityPublic) { + blogVisibility = 1; + } else if (visibility == WordPressComServiceBlogVisibilityPrivate) { + blogVisibility = -1; + } else { + // Hidden + blogVisibility = 0; + } + + NSDictionary *params = @{ + @"blog_name": blogUrl, + @"blog_title": blogTitle, + @"lang_id": languageId, + @"public": @(blogVisibility), + @"validate": @(validate), + @"client_id": clientID, + @"client_secret": clientSecret + }; + + + NSString *requestUrl = [self pathForEndpoint:@"sites/new" + withVersion:WordPressComRESTAPIVersion_1_1]; + + [self.wordPressComRESTAPI post:requestUrl parameters:params success:successBlock failure:failureBlock]; +} + +#pragma mark - Error localization + +- (NSError *)errorWithLocalizedMessage:(NSError *)error { + NSError *errorWithLocalizedMessage = error; + if ([error.domain isEqual:WordPressComRestApiErrorDomain] && + [error.userInfo objectForKey:self.wordPressComRESTAPI.errorCodeKey] != nil) { + + NSString *localizedErrorMessage = [self errorMessageForError:error]; + NSString *errorCode = [error.userInfo objectForKey:self.wordPressComRESTAPI.errorCodeKey]; + NSMutableDictionary *userInfo = [[NSMutableDictionary alloc] initWithDictionary:error.userInfo]; + userInfo[self.wordPressComRESTAPI.errorCodeKey] = errorCode; + userInfo[self.wordPressComRESTAPI.errorMessageKey] = localizedErrorMessage; + userInfo[NSLocalizedDescriptionKey] = localizedErrorMessage; + errorWithLocalizedMessage = [[NSError alloc] initWithDomain:error.domain code:error.code userInfo:userInfo]; + } + return errorWithLocalizedMessage; +} + +- (NSString *)errorMessageForError:(NSError *)error +{ + NSString *errorCode = [error.userInfo stringForKey:self.wordPressComRESTAPI.errorCodeKey]; + NSString *errorMessage = [[error.userInfo stringForKey:NSLocalizedDescriptionKey] wpkit_stringByStrippingHTML]; + + if ([errorCode isEqualToString:@"username_only_lowercase_letters_and_numbers"]) { + return NSLocalizedString(@"Sorry, usernames can only contain lowercase letters (a-z) and numbers.", nil); + } else if ([errorCode isEqualToString:@"username_required"]) { + return NSLocalizedString(@"Please enter a username.", nil); + } else if ([errorCode isEqualToString:@"username_not_allowed"]) { + return NSLocalizedString(@"That username is not allowed.", nil); + } else if ([errorCode isEqualToString:@"email_cant_be_used_to_signup"]) { + return NSLocalizedString(@"You cannot use that email address to signup. We are having problems with them blocking some of our email. Please use another email provider.", nil); + } else if ([errorCode isEqualToString:@"username_must_be_at_least_four_characters"]) { + return NSLocalizedString(@"Username must be at least 4 characters.", nil); + } else if ([errorCode isEqualToString:@"username_contains_invalid_characters"]) { + return NSLocalizedString(@"Sorry, usernames may not contain the character “_”!", nil); + } else if ([errorCode isEqualToString:@"username_must_include_letters"]) { + return NSLocalizedString(@"Sorry, usernames must have letters (a-z) too!", nil); + } else if ([errorCode isEqualToString:@"email_not_allowed"]) { + return NSLocalizedString(@"Sorry, that email address is not allowed!", nil); + } else if ([errorCode isEqualToString:@"username_exists"]) { + return NSLocalizedString(@"Sorry, that username already exists!", nil); + } else if ([errorCode isEqualToString:@"email_exists"]) { + return NSLocalizedString(@"Sorry, that email address is already being used!", nil); + } else if ([errorCode isEqualToString:@"username_reserved_but_may_be_available"]) { + return NSLocalizedString(@"That username is currently reserved but may be available in a couple of days.", nil); + } else if ([errorCode isEqualToString:@"username_unavailable"]) { + return NSLocalizedString(@"Sorry, that username is unavailable.", nil); + } else if ([errorCode isEqualToString:@"email_reserved"]) { + return NSLocalizedString(@"That email address has already been used. Please check your inbox for an activation email. If you don't activate you can try again in a few days.", nil); + } else if ([errorCode isEqualToString:@"blog_name_required"]) { + return NSLocalizedString(@"Please enter a site address.", nil); + } else if ([errorCode isEqualToString:@"blog_name_not_allowed"]) { + return NSLocalizedString(@"That site address is not allowed.", nil); + } else if ([errorCode isEqualToString:@"blog_name_must_be_at_least_four_characters"]) { + return NSLocalizedString(@"Site address must be at least 4 characters.", nil); + } else if ([errorCode isEqualToString:@"blog_name_must_be_less_than_sixty_four_characters"]) { + return NSLocalizedString(@"The site address must be shorter than 64 characters.", nil); + } else if ([errorCode isEqualToString:@"blog_name_contains_invalid_characters"]) { + return NSLocalizedString(@"Sorry, site addresses may not contain the character “_”!", nil); + } else if ([errorCode isEqualToString:@"blog_name_cant_be_used"]) { + return NSLocalizedString(@"Sorry, you may not use that site address.", nil); + } else if ([errorCode isEqualToString:@"blog_name_only_lowercase_letters_and_numbers"]) { + return NSLocalizedString(@"Sorry, site addresses can only contain lowercase letters (a-z) and numbers.", nil); + } else if ([errorCode isEqualToString:@"blog_name_must_include_letters"]) { + return NSLocalizedString(@"Sorry, site addresses must have letters too!", nil); + } else if ([errorCode isEqualToString:@"blog_name_exists"]) { + return NSLocalizedString(@"Sorry, that site already exists!", nil); + } else if ([errorCode isEqualToString:@"blog_name_reserved"]) { + return NSLocalizedString(@"Sorry, that site is reserved!", nil); + } else if ([errorCode isEqualToString:@"blog_name_reserved_but_may_be_available"]) { + return NSLocalizedString(@"That site is currently reserved but may be available in a couple days.", nil); + } else if ([errorCode isEqualToString:@"password_invalid"]) { + return NSLocalizedString(@"Sorry, that password does not meet our security guidelines. Please choose a password with a minimum length of six characters, mixing uppercase letters, lowercase letters, numbers and symbols.", @"This error message occurs when a user tries to create an account with a weak password."); + } else if ([errorCode isEqualToString:@"blog_title_invalid"]) { + return NSLocalizedString(@"Invalid Site Title", @""); + } else if ([errorCode isEqualToString:@"username_illegal_wpcom"]) { + // Try to extract the illegal phrase + NSError *error; + NSRegularExpression *regEx = [NSRegularExpression regularExpressionWithPattern:@"\"([^\"].*)\"" options:NSRegularExpressionCaseInsensitive error:&error]; + NSArray *matches = [regEx matchesInString:errorMessage options:0 range:NSMakeRange(0, [errorMessage length])]; + NSString *invalidPhrase = @""; + for (NSTextCheckingResult *result in matches) { + if ([result numberOfRanges] < 2) + continue; + NSRange invalidTextRange = [result rangeAtIndex:1]; + invalidPhrase = [NSString stringWithFormat:@" (\"%@\")", [errorMessage substringWithRange:invalidTextRange]]; + } + + return [NSString stringWithFormat:NSLocalizedString(@"Sorry, but your username contains an invalid phrase%@.", @"This error message occurs when a user tries to create a username that contains an invalid phrase for WordPress.com. The %@ may include the phrase in question if it was sent down by the API"), invalidPhrase]; + } + + // We have a few ambiguous errors that come back from the api, they sometimes have error messages included so + // attempt to return that if possible. If not fall back to a generic error. + NSDictionary *ambiguousErrors = @{ + @"email_invalid": NSLocalizedString(@"Please enter a valid email address.", nil), + @"blog_name_invalid" : NSLocalizedString(@"Invalid Site Address", @""), + @"username_invalid" : NSLocalizedString(@"Invalid username", @"") + }; + if ([ambiguousErrors.allKeys containsObject:errorCode]) { + if (errorMessage != nil) { + return errorMessage; + } + + return [ambiguousErrors objectForKey:errorCode]; + } + + // Return an error message if there's one included rather than the unhelpful "Unknown Error" + if (errorMessage != nil) { + return errorMessage; + } + + return NSLocalizedString(@"Unknown error", nil); +} + +@end diff --git a/Modules/Sources/WordPressKitObjC/include/AccountServiceRemote.h b/Modules/Sources/WordPressKitObjC/include/AccountServiceRemote.h new file mode 100644 index 000000000000..c891b1c62f05 --- /dev/null +++ b/Modules/Sources/WordPressKitObjC/include/AccountServiceRemote.h @@ -0,0 +1,113 @@ +#import + +@class RemoteUser; +@class WPAccount; + +static NSString * const AccountServiceRemoteErrorDomain = @"AccountServiceErrorDomain"; + +typedef NS_ERROR_ENUM(AccountServiceRemoteErrorDomain, AccountServiceRemoteError) { + AccountServiceRemoteCantReadServerResponse, + AccountServiceRemoteEmailAddressInvalid, + AccountServiceRemoteEmailAddressCheckError, +}; + +@protocol AccountServiceRemote + +/** + * @brief Gets blogs for an account. + * + * @param filterJetpackSites Whether we're fetching only Jetpack blogs. + * @param success The block that will be executed on success. Can be nil. + * @param failure The block that will be executed on failure. Can be nil. + */ +- (void)getBlogs:(BOOL)filterJetpackSites + success:(void (^)(NSArray *blogs))success + failure:(void (^)(NSError *error))failure; + +/** + * @brief Gets all blogs for an account. + * + * @param success The block that will be executed on success. Can be nil. + * @param failure The block that will be executed on failure. Can be nil. + */ +- (void)getBlogsWithSuccess:(void (^)(NSArray *blogs))success + failure:(void (^)(NSError *error))failure; + + +/** + * @brief Gets only visible blogs for an account. + * + * @discussion This method is designed for use in extensions in order to provide a simple + * way to retrieve a quick list of availible sites. + * + * @param success The block that will be executed on success. Can be nil. + * @param failure The block that will be executed on failure. Can be nil. + */ +- (void)getVisibleBlogsWithSuccess:(void (^)(NSArray *))success + failure:(void (^)(NSError *))failure; + +/** + * @brief Gets an account's details. + * + * @param success The block that will be executed on success. Can be nil. + * @param failure The block that will be executed on failure. Can be nil. + */ +- (void)getAccountDetailsWithSuccess:(void (^)(RemoteUser *remoteUser))success + failure:(void (^)(NSError *error))failure; + +/** + * @brief Updates blogs' visibility + * + * @param blogs A dictionary with blog IDs as keys and a boolean indicating visibility as values. + * @param success The block that will be executed on success. Can be nil. + * @param failure The block that will be executed on failure. Can be nil. + */ +- (void)updateBlogsVisibility:(NSDictionary *)blogs + success:(void (^)(void))success + failure:(void (^)(NSError *error))failure; + +/** + * @brief Query to check if a wpcom account requires a passwordless login option. + * @note Note that if there is no acccount matching the supplied identifier + * the REST endpoing returns a 404 error code. + * + * @param identifier May be an email address, username, or user ID. + * @param success The block that will be executed on success. Can be nil. + * @param failure The block that will be executed on failure. Can be nil. + */ +- (void)isPasswordlessAccount:(NSString *)identifier + success:(void (^)(BOOL passwordless))success + failure:(void (^)(NSError *error))failure; + +/** + * @brief Query to see if an email address is paired with a wpcom acccount + * or if it is available. Used in the auth link signup flow. + * + * @param success The block that will be executed on success. Can be nil. + * @param failure The block that will be executed on failure. Can be nil. + */ +- (void)isEmailAvailable:(NSString *)email + success:(void (^)(BOOL available))success + failure:(void (^)(NSError *error))failure; + +/** + * @brief Query to see if a username is available. Used in the auth link signup flow. + * @note This is an unversioned endpoint. Success will mean, generally, that the username already exists. + * + * @param success The block that will be executed on success. Can be nil. + * @param failure The block that will be executed on failure. Can be nil. + */ +- (void)isUsernameAvailable:(NSString *)username + success:(void (^)(BOOL available))success + failure:(void (^)(NSError *error))failure; + + /** + * @brief Request to (re-)send the verification email for the current user. + * + * @param success The block that will be executed on success. Can be nil. + * @param failure The block that will be executed on failure. Can be nil. + */ +- (void)requestVerificationEmailWithSucccess:(void (^)(void))success + failure:(void (^)(NSError *error))failure; + +@end diff --git a/Modules/Sources/WordPressKitObjC/include/AccountServiceRemoteREST.h b/Modules/Sources/WordPressKitObjC/include/AccountServiceRemoteREST.h new file mode 100644 index 000000000000..dbdaa9d9950f --- /dev/null +++ b/Modules/Sources/WordPressKitObjC/include/AccountServiceRemoteREST.h @@ -0,0 +1,62 @@ +#import +#import "AccountServiceRemote.h" +#import "ServiceRemoteWordPressComREST.h" + +typedef NSString* const MagicLinkParameter NS_TYPED_ENUM; +extern MagicLinkParameter const MagicLinkParameterFlow; +extern MagicLinkParameter const MagicLinkParameterSource; + +typedef NSString* const MagicLinkSource NS_TYPED_ENUM; +extern MagicLinkSource const MagicLinkSourceDefault; +extern MagicLinkSource const MagicLinkSourceJetpackConnect; + +//typedef NSString* const MagicLinkFlow NS_TYPED_ENUM; +typedef NSString* const MagicLinkFlow NS_STRING_ENUM; +extern MagicLinkFlow const MagicLinkFlowLogin; +extern MagicLinkFlow const MagicLinkFlowSignup; + +@interface AccountServiceRemoteREST : ServiceRemoteWordPressComREST + +/** +* @brief Request an authentication link be sent to the email address provided. +* + * @param success The block that will be executed on success. Can be nil. + * @param failure The block that will be executed on failure. Can be nil. + */ +- (void)requestWPComAuthLinkForEmail:(NSString *)email + clientID:(NSString *)clientID + clientSecret:(NSString *)clientSecret + source:(MagicLinkSource)source + wpcomScheme:(NSString *)scheme + createAccountIfNotFound:(BOOL)createAccountIfNotFound + success:(void (^)(void))success + failure:(void (^)(NSError *error))failure; + +/** +* @brief Request an authentication link be sent to the email address provided. +* + * @param success The block that will be executed on success. Can be nil. + * @param failure The block that will be executed on failure. Can be nil. + */ +- (void)requestWPComAuthLinkForEmail:(NSString *)email + clientID:(NSString *)clientID + clientSecret:(NSString *)clientSecret + source:(MagicLinkSource)source + wpcomScheme:(NSString *)scheme + success:(void (^)(void))success + failure:(void (^)(NSError *error))failure; + +/** + * @brief Request a signup link be sent to the email address provided. + * + * @param success The block that will be executed on success. Can be nil. + * @param failure The block that will be executed on failure. Can be nil. + */ +- (void)requestWPComSignupLinkForEmail:(NSString *)email + clientID:(NSString *)clientID + clientSecret:(NSString *)clientSecret + wpcomScheme:(NSString *)scheme + success:(void (^)(void))success + failure:(void (^)(NSError *error))failure; + +@end diff --git a/Modules/Sources/WordPressKitObjC/include/BlogServiceRemote.h b/Modules/Sources/WordPressKitObjC/include/BlogServiceRemote.h new file mode 100644 index 000000000000..23c9e9a5d924 --- /dev/null +++ b/Modules/Sources/WordPressKitObjC/include/BlogServiceRemote.h @@ -0,0 +1,43 @@ +#import + +@class RemoteBlog; +@class RemoteBlogSettings; +@class RemotePostType; +@class RemoteUser; + +typedef void (^PostTypesHandler)(NSArray *postTypes); +typedef void (^PostFormatsHandler)(NSDictionary *postFormats); +typedef void (^UsersHandler)(NSArray *users); +typedef void (^MultiAuthorCheckHandler)(BOOL isMultiAuthor); +typedef void (^SuccessHandler)(void); + +@protocol BlogServiceRemote + +/** + Synchronizes all blog's authors. + + @param success The block that will be executed on success. Can be nil. + @param failure The block that will be executed on failure. Can be nil. + */ +- (void)getAllAuthorsWithSuccess:(UsersHandler)success + failure:(void (^)(NSError *error))failure; + +/** + * @brief Synchronizes a blog's post types. + * + * @param success The block that will be executed on success. Can be nil. + * @param failure The block that will be executed on failure. Can be nil. + */ +- (void)syncPostTypesWithSuccess:(PostTypesHandler)success + failure:(void (^)(NSError *error))failure; + +/** + * @brief Synchronizes a blog's post formats. + * + * @param success The block that will be executed on success. Can be nil. + * @param failure The block that will be executed on failure. Can be nil. + */ +- (void)syncPostFormatsWithSuccess:(PostFormatsHandler)success + failure:(void (^)(NSError *error))failure; + +@end diff --git a/Modules/Sources/WordPressKitObjC/include/BlogServiceRemoteREST.h b/Modules/Sources/WordPressKitObjC/include/BlogServiceRemoteREST.h new file mode 100644 index 000000000000..926840a80ce5 --- /dev/null +++ b/Modules/Sources/WordPressKitObjC/include/BlogServiceRemoteREST.h @@ -0,0 +1,69 @@ +#import +#import "BlogServiceRemote.h" +#import "SiteServiceRemoteWordPressComREST.h" + +typedef void (^BlogDetailsHandler)(RemoteBlog *remoteBlog); +typedef void (^SettingsHandler)(RemoteBlogSettings *settings); + +@interface BlogServiceRemoteREST : SiteServiceRemoteWordPressComREST + +/** + * @brief Synchronizes a blog and its top-level details. + * + * @note Requires WPCOM/Jetpack APIs. + * + * @param success The block that will be executed on success. Can be nil. + * @param failure The block that will be executed on failure. Can be nil. + */ +- (void)syncBlogWithSuccess:(BlogDetailsHandler)success + failure:(void (^)(NSError *error))failure; + +/** + * @brief Synchronizes a blog's settings. + * + * @note Requires WPCOM/Jetpack APIs. + * + * @param success The block that will be executed on success. Can be nil. + * @param failure The block that will be executed on failure. Can be nil. + */ +- (void)syncBlogSettingsWithSuccess:(SettingsHandler)success + failure:(void (^)(NSError *error))failure; + +/** + * @brief Updates the blog settings. + * + * @note Requires WPCOM/Jetpack APIs. + * + * @param success The block that will be executed on success. Can be nil. + * @param failure The block that will be executed on failure. Can be nil. + */ +- (void)updateBlogSettings:(RemoteBlogSettings *)remoteBlogSettings + success:(SuccessHandler)success + failure:(void (^)(NSError *error))failure; + + +/** + * @brief Fetch site info for the specified site address. + * + * @note Uses anonymous API + * + * @param success The block that will be executed on success. Can be nil. + * @param failure The block that will be executed on failure. Can be nil. + */ +- (void)fetchSiteInfoForAddress:(NSString *)siteAddress + success:(void(^)(NSDictionary *siteInfoDict))success + failure:(void (^)(NSError *error))failure; + +/** + * @brief Fetch site info (does not require authentication) for the specified site address. + * + * @note Uses anonymous API + * + * @param success The block that will be executed on success. Can be nil. + * @param failure The block that will be executed on failure. Can be nil. + */ +- (void)fetchUnauthenticatedSiteInfoForAddress:(NSString *)siteAddress + success:(void(^)(NSDictionary *siteInfoDict))success + failure:(void (^)(NSError *error))failure; + +@end diff --git a/Modules/Sources/WordPressKitObjC/include/BlogServiceRemoteXMLRPC.h b/Modules/Sources/WordPressKitObjC/include/BlogServiceRemoteXMLRPC.h new file mode 100644 index 000000000000..225ec619fd39 --- /dev/null +++ b/Modules/Sources/WordPressKitObjC/include/BlogServiceRemoteXMLRPC.h @@ -0,0 +1,32 @@ +#import +#import "BlogServiceRemote.h" +#import "ServiceRemoteWordPressXMLRPC.h" + +typedef void (^OptionsHandler)(NSDictionary *options); + +@interface BlogServiceRemoteXMLRPC : ServiceRemoteWordPressXMLRPC + +/** + * @brief Synchronizes a blog's options. + * + * @note Available in XML-RPC only. + * + * @param success The block that will be executed on success. Can be nil. + * @param failure The block that will be executed on failure. Can be nil. + */ +- (void)syncBlogOptionsWithSuccess:(OptionsHandler)success + failure:(void (^)(NSError *error))failure; + +/** + * @brief Update a blog's options. + * + * @note Available in XML-RPC only. + * + * @param success The block that will be executed on success. Can be nil. + * @param failure The block that will be executed on failure. Can be nil. + */ +- (void)updateBlogOptionsWith:(NSDictionary *)remoteBlogOptions + success:(SuccessHandler)success + failure:(void (^)(NSError *error))failure; + +@end diff --git a/Modules/Sources/WordPressKitObjC/include/CommentServiceRemote.h b/Modules/Sources/WordPressKitObjC/include/CommentServiceRemote.h new file mode 100644 index 000000000000..1f7e11c5a130 --- /dev/null +++ b/Modules/Sources/WordPressKitObjC/include/CommentServiceRemote.h @@ -0,0 +1,69 @@ +#import +#import "RemoteComment.h" + + +// Used to determine which 'status' parameter to use when fetching Comments. +typedef enum { + CommentStatusFilterAll = 0, + CommentStatusFilterUnapproved, + CommentStatusFilterApproved, + CommentStatusFilterTrash, + CommentStatusFilterSpam, +} CommentStatusFilter; + + +@protocol CommentServiceRemote + +/** + Loads all of the comments associated with a blog + */ +- (void)getCommentsWithMaximumCount:(NSInteger)maximumComments + success:(void (^)(NSArray *comments))success + failure:(void (^)(NSError *error))failure; + + + +/** + Loads all of the comments associated with a blog + */ +- (void)getCommentsWithMaximumCount:(NSInteger)maximumComments + options:(NSDictionary *)options + success:(void (^)(NSArray *posts))success + failure:(void (^)(NSError *error))failure; + + +/** + Loads the specified comment associated with a blog + */ +- (void)getCommentWithID:(NSNumber *)commentID + success:(void (^)(RemoteComment *comment))success + failure:(void (^)(NSError * error))failure; + +/** + Publishes a new comment + */ +- (void)createComment:(RemoteComment *)comment + success:(void (^)(RemoteComment *comment))success + failure:(void (^)(NSError *error))failure; +/** + Updates the content of an existing comment + */ +- (void)updateComment:(RemoteComment *)commen + success:(void (^)(RemoteComment *comment))success + failure:(void (^)(NSError *error))failure; + +/** + Updates the status of an existing comment + */ +- (void)moderateComment:(RemoteComment *)comment + success:(void (^)(RemoteComment *comment))success + failure:(void (^)(NSError *error))failure; + +/** + Trashes a comment + */ +- (void)trashComment:(RemoteComment *)comment + success:(void (^)(void))success + failure:(void (^)(NSError *error))failure; + +@end diff --git a/Modules/Sources/WordPressKitObjC/include/CommentServiceRemoteREST.h b/Modules/Sources/WordPressKitObjC/include/CommentServiceRemoteREST.h new file mode 100644 index 000000000000..6f82dc4f7d86 --- /dev/null +++ b/Modules/Sources/WordPressKitObjC/include/CommentServiceRemoteREST.h @@ -0,0 +1,100 @@ +#import +#import "CommentServiceRemote.h" +#import "SiteServiceRemoteWordPressComREST.h" + +@class RemoteUser; +@class RemoteLikeUser; + +@interface CommentServiceRemoteREST : SiteServiceRemoteWordPressComREST + +/** + Fetch a hierarchical list of comments for the specified post on the specified site. + The comments are returned in the order of nesting, not date. + The request fetches the default number of *parent* comments (20) but may return more + depending on the number of child comments. + + @param postID The ID of the post. + @param page The page number to fetch. + @param number The number to fetch per page. + @param success block called on a successful fetch. Returns the comments array and total comments count. + @param failure block called if there is any error. `error` can be any underlying network error. + */ +- (void)syncHierarchicalCommentsForPost:(NSNumber * _Nonnull)postID + page:(NSUInteger)page + number:(NSUInteger)number + success:(void (^ _Nullable)(NSArray * _Nullable comments, NSNumber * _Nonnull found))success + failure:(void (^ _Nullable)(NSError * _Nullable error))failure; + +/** + Update a comment with a commentID + */ +- (void)updateCommentWithID:(NSNumber * _Nonnull)commentID + content:(NSString * _Nonnull)content + success:(void (^ _Nullable)(RemoteComment * _Nullable comment))success + failure:(void (^ _Nullable)(NSError * _Nullable error))failure; + +/** + Adds a reply to a post with postID + */ +- (void)replyToPostWithID:(NSNumber * _Nonnull)postID + content:(NSString * _Nonnull)content + success:(void (^ _Nullable)(RemoteComment * _Nullable comment))success + failure:(void (^ _Nullable)(NSError * _Nullable error))failure; + +/** + Adds a reply to a comment with commentID. + */ +- (void)replyToCommentWithID:(NSNumber * _Nonnull)commentID + content:(NSString * _Nonnull)content + success:(void (^ _Nullable)(RemoteComment * _Nullable comment))success + failure:(void (^ _Nullable)(NSError * _Nullable error))failure; + +/** + Moderate a comment with a commentID + */ +- (void)moderateCommentWithID:(NSNumber * _Nonnull)commentID + status:(NSString * _Nonnull)status + success:(void (^ _Nullable)(void))success + failure:(void (^ _Nullable)(NSError * _Nullable error))failure; + +/** + Trashes a comment with a commentID + */ +- (void)trashCommentWithID:(NSNumber * _Nonnull)commentID + success:(void (^ _Nullable)(void))success + failure:(void (^ _Nullable)(NSError * _Nullable error))failure; + +/** + Like a comment with a commentID + */ +- (void)likeCommentWithID:(NSNumber * _Nonnull)commentID + success:(void (^ _Nullable)(void))success + failure:(void (^ _Nullable)(NSError * _Nullable error))failure; + + +/** + Unlike a comment with a commentID + */ +- (void)unlikeCommentWithID:(NSNumber * _Nonnull)commentID + success:(void (^ _Nullable)(void))success + failure:(void (^ _Nullable)(NSError * _Nullable error))failure; + +/** + Requests a list of users that liked the comment with the specified ID. Due to + API limitation, up to 90 users will be returned from the endpoint. + + @param commentID The ID for the comment. Cannot be nil. + @param count Number of records to retrieve. Cannot be nil. If 0, will default to endpoint max. + @param before Filter results to Likes before this date/time string. Can be nil. + @param excludeUserIDs Array of user IDs to exclude from response. Can be nil. + @param success The block that will be executed on success. Can be nil. + @param failure The block that will be executed on failure. Can be nil. + */ +- (void)getLikesForCommentID:(NSNumber * _Nonnull)commentID + count:(NSNumber * _Nonnull)count + before:(NSString * _Nullable)before + excludeUserIDs:(NSArray * _Nullable)excludeUserIDs + success:(void (^ _Nullable)(NSArray * _Nonnull users, NSNumber * _Nonnull found))success + failure:(void (^ _Nullable)(NSError * _Nullable))failure; + +@end diff --git a/Modules/Sources/WordPressKitObjC/include/CommentServiceRemoteXMLRPC.h b/Modules/Sources/WordPressKitObjC/include/CommentServiceRemoteXMLRPC.h new file mode 100644 index 000000000000..2f5bcfa73e4d --- /dev/null +++ b/Modules/Sources/WordPressKitObjC/include/CommentServiceRemoteXMLRPC.h @@ -0,0 +1,7 @@ +#import +#import "CommentServiceRemote.h" +#import "ServiceRemoteWordPressXMLRPC.h" + +@interface CommentServiceRemoteXMLRPC : ServiceRemoteWordPressXMLRPC + +@end diff --git a/Modules/Sources/WordPressKitObjC/include/DisplayableImageHelper.h b/Modules/Sources/WordPressKitObjC/include/DisplayableImageHelper.h new file mode 100644 index 000000000000..06e2a7e1c487 --- /dev/null +++ b/Modules/Sources/WordPressKitObjC/include/DisplayableImageHelper.h @@ -0,0 +1,38 @@ +#import + +/** + Helper for searching a post's content or attachments for an image suitable for + using as the displayed image in the post list. + */ +@interface WPKitDisplayableImageHelper : NSObject + +/** + Get the url path of the image to display for a post. + + @param attachmentsDict A dictionary representing a posts attachments from the REST API. + @param content The post content. The attachment url must exist in the content. + @return The url path for the featured image or nil + */ ++ (NSString *)searchPostAttachmentsForImageToDisplay:(NSDictionary *)attachmentsDict existingInContent:(NSString *)content; + +/** + Search the passed string for an image that is a good candidate to feature. + + @details Loops over all img tags in the passed html content, extracts the URL from the + src attribute and checks for an acceptable width. The image URL with the best + width is returned. + @param content The content string to search. + @return The URL path for the image or an empty string. + */ ++ (NSString *)searchPostContentForImageToDisplay:(NSString *)content; + +/** + Find attachments ids in post content + + @param content The content string to search + + @return A set with all the attachment id that where found in galleries + */ ++ (NSSet *)searchPostContentForAttachmentIdsInGalleries:(NSString *)content; + +@end diff --git a/Modules/Sources/WordPressKitObjC/include/FilePart.h b/Modules/Sources/WordPressKitObjC/include/FilePart.h new file mode 100644 index 000000000000..5590a5fc1404 --- /dev/null +++ b/Modules/Sources/WordPressKitObjC/include/FilePart.h @@ -0,0 +1,16 @@ +#import + +/// Represents the infomartion needed to encode a file on a multipart form request. +@interface FilePart: NSObject + +@property (strong, nonatomic) NSString * _Nonnull parameterName; +@property (strong, nonatomic) NSURL * _Nonnull url; +@property (strong, nonatomic) NSString * _Nonnull fileName; +@property (strong, nonatomic) NSString * _Nonnull mimeType; + +- (instancetype _Nonnull)initWithParameterName:(NSString * _Nonnull)parameterName + url:(NSURL * _Nonnull)url + fileName:(NSString * _Nonnull)fileName + mimeType:(NSString * _Nonnull)mimeType; + +@end diff --git a/Modules/Sources/WordPressKitObjC/include/MediaServiceRemote.h b/Modules/Sources/WordPressKitObjC/include/MediaServiceRemote.h new file mode 100644 index 000000000000..30be85ea4908 --- /dev/null +++ b/Modules/Sources/WordPressKitObjC/include/MediaServiceRemote.h @@ -0,0 +1,97 @@ +#import + +@class RemoteMedia; +@class RemoteVideoPressVideo; + +@protocol MediaServiceRemote + + +- (void)getMediaWithID:(NSNumber *)mediaID + success:(void (^)(RemoteMedia *remoteMedia))success + failure:(void (^)(NSError *error))failure; + +- (void)uploadMedia:(RemoteMedia *)media + progress:(NSProgress **)progress + success:(void (^)(RemoteMedia *remoteMedia))success + failure:(void (^)(NSError *error))failure; + +/** + * Update media details on the server + * + * @param media the media object to update + * @param success a block to be executed when the request finishes with success. + * @param failure a block to be executed when the request fails. + */ +- (void)updateMedia:(RemoteMedia *)media + success:(void (^)(RemoteMedia *remoteMedia))success + failure:(void (^)(NSError *error))failure; + +/** + * Delete media from the server. Note the media is deleted, not trashed. + * + * @param media the media object to delete + * @param success a block to be executed when the request finishes with success. + * @param failure a block to be executed when the request fails. + */ +- (void)deleteMedia:(RemoteMedia *)media + success:(void (^)(void))success + failure:(void (^)(NSError *error))failure; + +/** + * Get all WordPress Media Library items in batches. + * + * The `pageLoad` block is called with media items in each page, except the last page. If there is only one page of media + * items, the `pageLoad` block will not be called. + * + * The `success` block is called with all media items in the Media Library. Calling this block marks the end of the loading. + * + * The `failure` block is called when any API call fails. Calling this block marks the end of the loading. + * + * @param pageLoad a block to be executed when each page of media is loaded. + * @param success a block to be executed when the request finishes with success. + * @param failure a block to be execute when the request fails. + */ +- (void)getMediaLibraryWithPageLoad:(void (^)(NSArray *))pageLoad + success:(void (^)(NSArray *))success + failure:(void (^)(NSError *))failure; + +/** + * Get the number of media items available in the blog + * + * @param mediaType the type of media to count for (image, video, audio, application) + * @param success a block to be executed when the request finishes with success. + * @param failure a block to be execute when the request fails. + */ +- (void)getMediaLibraryCountForType:(NSString *)mediaType + withSuccess:(void (^)(NSInteger))success + failure:(void (^)(NSError *))failure; + +/** + * Retrieves the metadata of a VideoPress video. + * + * The metadata parameters can be found in the API reference: + * https://developer.wordpress.com/docs/api/1.1/get/videos/%24guid/ + * + * @param videoPressID ID of the video in VideoPress. + * @param isSitePrivate true if the site is private, this will be used to determine the fetch of the VideoPress token. + * @param success a block to be executed when the metadata is fetched successfully. + * @param failure a block to be executed when the metadata can't be fetched. + */ +-(void)getMetadataFromVideoPressID:(NSString *)videoPressID + isSitePrivate:(BOOL)isSitePrivate + success:(void (^)(RemoteVideoPressVideo *metadata))success + failure:(void (^)(NSError *))failure; + +/** + Retrieves the VideoPress token for the request videoPressID. + The token is required to play private VideoPress videos. + + @param videoPressID the videoPressID to search for. + @param success a block to be executed if the the token is fetched successfully for the VideoPress video. + @param failure a block to be executed if the token can't be fetched for the VideoPress video. + */ +-(void)getVideoPressToken:(NSString *)videoPressID + success:(void (^)(NSString *token))success + failure:(void (^)(NSError *))failure; + +@end diff --git a/Modules/Sources/WordPressKitObjC/include/MediaServiceRemoteREST.h b/Modules/Sources/WordPressKitObjC/include/MediaServiceRemoteREST.h new file mode 100644 index 000000000000..1b8e8cb5af55 --- /dev/null +++ b/Modules/Sources/WordPressKitObjC/include/MediaServiceRemoteREST.h @@ -0,0 +1,40 @@ +#import +#import "MediaServiceRemote.h" +#import "SiteServiceRemoteWordPressComREST.h" + +@interface MediaServiceRemoteREST : SiteServiceRemoteWordPressComREST + +/** + Populates a RemoteMedia instance using values from a json dict returned + from the endpoint. + + @param jsonMedia Media dictionary returned from the remote endpoint + @return A RemoteMedia instance + */ ++ (RemoteMedia *)remoteMediaFromJSONDictionary:(NSDictionary *)jsonMedia; + + +/** + Populates a array of RemoteMedia instances using an array of json dicts returned + from the endpoint. + + @param jsonMedia An array of media dicts returned from the remote endpoint + @return A array of RemoteMedia instances + */ ++ (NSArray *)remoteMediaFromJSONArray:(NSArray *)jsonMedia; + +/** + * @brief Upload multiple media items to the remote site. + * + * @discussion This purpose of this method is to give app extensions the ability to upload media via background sessions. + * + * @param mediaItems The media items to create remotely. + * @param requestEnqueued The block that will be executed when the network request is queued. Can be nil. + * @param success The block that will be executed on success. Can be nil. + * @param failure The block that will be executed on failure. Can be nil. + */ +- (void)uploadMedia:(NSArray *)mediaItems + requestEnqueued:(void (^)(NSNumber *taskID))requestEnqueued + success:(void (^)(NSArray *remoteMedia))success + failure:(void (^)(NSError *error))failure; +@end diff --git a/Modules/Sources/WordPressKitObjC/include/MediaServiceRemoteXMLRPC.h b/Modules/Sources/WordPressKitObjC/include/MediaServiceRemoteXMLRPC.h new file mode 100644 index 000000000000..0b24f2a7250a --- /dev/null +++ b/Modules/Sources/WordPressKitObjC/include/MediaServiceRemoteXMLRPC.h @@ -0,0 +1,6 @@ +#import +#import "MediaServiceRemote.h" +#import "ServiceRemoteWordPressXMLRPC.h" + +@interface MediaServiceRemoteXMLRPC : ServiceRemoteWordPressXMLRPC +@end diff --git a/Modules/Sources/WordPressKitObjC/include/MenusServiceRemote.h b/Modules/Sources/WordPressKitObjC/include/MenusServiceRemote.h new file mode 100644 index 000000000000..f253ae981c73 --- /dev/null +++ b/Modules/Sources/WordPressKitObjC/include/MenusServiceRemote.h @@ -0,0 +1,97 @@ +#import +#import "SiteServiceRemoteWordPressComREST.h" + +NS_ASSUME_NONNULL_BEGIN + +extern NSString * const MenusRemoteKeyID; +extern NSString * const MenusRemoteKeyMenu; +extern NSString * const MenusRemoteKeyMenus; +extern NSString * const MenusRemoteKeyLocations; +extern NSString * const MenusRemoteKeyContentID; +extern NSString * const MenusRemoteKeyDescription; +extern NSString * const MenusRemoteKeyLinkTarget; +extern NSString * const MenusRemoteKeyLinkTitle; +extern NSString * const MenusRemoteKeyName; +extern NSString * const MenusRemoteKeyType; +extern NSString * const MenusRemoteKeyTypeFamily; +extern NSString * const MenusRemoteKeyTypeLabel; +extern NSString * const MenusRemoteKeyURL; +extern NSString * const MenusRemoteKeyItems; +extern NSString * const MenusRemoteKeyDeleted; +extern NSString * const MenusRemoteKeyLocationDefaultState; + +@class RemoteMenu; +@class RemoteMenuItem; +@class RemoteMenuLocation; + +typedef void(^MenusServiceRemoteSuccessBlock)(void); +typedef void(^MenusServiceRemoteMenuRequestSuccessBlock)(RemoteMenu *menu); +typedef void(^MenusServiceRemoteMenusRequestSuccessBlock)(NSArray * _Nullable menus, NSArray * _Nullable locations); +typedef void(^MenusServiceRemoteFailureBlock)(NSError * _Nonnull error); + +@interface MenusServiceRemote : ServiceRemoteWordPressComREST + +#pragma mark - Remote queries: Creating and modifying menus + +/** + * @brief Create a new menu on a blog. + * + * @param menuName The name of the new menu to be created. Cannot be nil. + * @param siteID The site ID to create the menu on. Cannot be nil. + * @param success The success handler. Can be nil. + * @param failure The failure handler. Can be nil. + * + */ +- (void)createMenuWithName:(NSString *)menuName + siteID:(NSNumber *)siteID + success:(nullable MenusServiceRemoteMenuRequestSuccessBlock)success + failure:(nullable MenusServiceRemoteFailureBlock)failure; + +/** + * @brief Update a menu on a blog. + * + * @param menuID The updated menu object to update remotely. Cannot be nil. + * @param siteID The site ID to update the menu on. Cannot be nil. + * @param success The success handler. Can be nil. + * @param failure The failure handler. Can be nil. + * + */ +- (void)updateMenuForID:(NSNumber *)menuID + siteID:(NSNumber *)siteID + withName:(nullable NSString *)updatedName + withLocations:(nullable NSArray *)locationNames + withItems:(nullable NSArray *)updatedItems + success:(nullable MenusServiceRemoteMenuRequestSuccessBlock)success + failure:(nullable MenusServiceRemoteFailureBlock)failure; + +/** + * @brief Delete a menu from a blog. + * + * @param menuID The menuId of the menu to delete remotely. Cannot be nil. + * @param siteID The site ID to delete the menu from. Cannot be nil. + * @param success The success handler. Can be nil. + * @param failure The failure handler. Can be nil. + * + */ +- (void)deleteMenuForID:(NSNumber *)menuID + siteID:(NSNumber *)siteID + success:(nullable MenusServiceRemoteSuccessBlock)success + failure:(nullable MenusServiceRemoteFailureBlock)failure; + +#pragma mark - Remote queries: Getting menus + +/** + * @brief Gets the available menus for a specific blog. + * + * @param siteID The site ID to get the available menus for. Cannot be nil. + * @param success The success handler. Can be nil. + * @param failure The failure handler. Can be nil. + * + */ +- (void)getMenusForSiteID:(NSNumber *)siteID + success:(nullable MenusServiceRemoteMenusRequestSuccessBlock)success + failure:(nullable MenusServiceRemoteFailureBlock)failure; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Modules/Sources/WordPressKitObjC/include/NSBundle+VersionNumberHelper.h b/Modules/Sources/WordPressKitObjC/include/NSBundle+VersionNumberHelper.h new file mode 100644 index 000000000000..10eba3612754 --- /dev/null +++ b/Modules/Sources/WordPressKitObjC/include/NSBundle+VersionNumberHelper.h @@ -0,0 +1,7 @@ +#import + +@interface NSBundle (WPKitVersionNumberHelper) + +- (NSString *)wpkit_bundleVersion; + +@end diff --git a/Modules/Sources/WordPressKitObjC/include/NSMutableDictionary+Helpers.h b/Modules/Sources/WordPressKitObjC/include/NSMutableDictionary+Helpers.h new file mode 100644 index 000000000000..1682d2e0da25 --- /dev/null +++ b/Modules/Sources/WordPressKitObjC/include/NSMutableDictionary+Helpers.h @@ -0,0 +1,5 @@ +#import + +@interface NSMutableDictionary (Helpers) +- (void)setValueIfNotNil:(id)value forKey:(NSString *)key; +@end diff --git a/Modules/Sources/WordPressKitObjC/include/PostServiceRemote.h b/Modules/Sources/WordPressKitObjC/include/PostServiceRemote.h new file mode 100644 index 000000000000..f083c324c86b --- /dev/null +++ b/Modules/Sources/WordPressKitObjC/include/PostServiceRemote.h @@ -0,0 +1,106 @@ +#import +#import "PostServiceRemoteOptions.h" + +@class RemotePost; +@class RemotePostUpdateParameters; + +@protocol PostServiceRemote + +/** + * @brief Requests the post with the specified ID. + * + * @param postID The ID of the post to get. Cannot be nil. + * @param success The block that will be executed on success. Can be nil. + * @param failure The block that will be executed on failure. Can be nil. + */ +- (void)getPostWithID:(NSNumber *)postID + success:(void (^)(RemotePost *post))success + failure:(void (^)(NSError *))failure; + +/** + * @brief Requests the posts of the specified type. + * + * @param postType The type of the posts to get. Cannot be nil. + * @param success The block that will be executed on success. Can be nil. + * @param failure The block that will be executed on failure. Can be nil. + */ +- (void)getPostsOfType:(NSString *)postType + success:(void (^)(NSArray *remotePosts))success + failure:(void (^)(NSError *error))failure; + +/** + * @brief Requests the posts of the specified type using the specified options. + * + * @param postType The type of the posts to get. Cannot be nil. + * @param options The options to use for the request. Can be nil. + * @param success The block that will be executed on success. Can be nil. + * @param failure The block that will be executed on failure. Can be nil. + */ +- (void)getPostsOfType:(NSString *)postType + options:(NSDictionary *)options + success:(void (^)(NSArray *remotePosts))success + failure:(void (^)(NSError *error))failure; + +/** + * @brief Creates a post remotely for the specified blog. + * + * @param post The post to create remotely. Cannot be nil. + * @param success The block that will be executed on success. Can be nil. + * @param failure The block that will be executed on failure. Can be nil. + */ +- (void)createPost:(RemotePost *)post + success:(void (^)(RemotePost *post))success + failure:(void (^)(NSError *error))failure; + +/** + * @brief Updates a blog's post. + * + * @param post The post to update. Cannot be nil. + * @param success The block that will be executed on success. Can be nil. + * @param failure The block that will be executed on failure. Can be nil. + */ +- (void)updatePost:(RemotePost *)post + success:(void (^)(RemotePost *post))success + failure:(void (^)(NSError *error))failure; + +/** + * @brief Deletes a post. + * + * @param post The post to delete. Cannot be nil. + * @param success The block that will be executed on success. Can be nil. + * @param failure The block that will be executed on failure. Can be nil. + */ +- (void)deletePost:(RemotePost *)post + success:(void (^)(void))success + failure:(void (^)(NSError *error))failure; + +/** + * @brief Trashes a post. + * + * @param post The post to trash. Cannot be nil. + * @param success The block that will be executed on success. Can be nil. + * @param failure The block that will be executed on failure. Can be nil. + */ +- (void)trashPost:(RemotePost *)post + success:(void (^)(RemotePost *))success + failure:(void (^)(NSError *))failure; + +/** + * @brief Restores a post. + * + * @param post The post to restore. Cannot be nil. + * @param success The block that will be executed on success. Can be nil. + * @param failure The block that will be executed on failure. Can be nil. + */ +- (void)restorePost:(RemotePost *)post + success:(void (^)(RemotePost *))success + failure:(void (^)(NSError *error))failure; + +/** + * @brief Returns a dictionary set with option parameters of the PostServiceRemoteOptions protocol. + * + * @param options The object with set remote options. Cannot be nil. + */ +- (NSDictionary *)dictionaryWithRemoteOptions:(id )options; + +@end diff --git a/Modules/Sources/WordPressKitObjC/include/PostServiceRemoteOptions.h b/Modules/Sources/WordPressKitObjC/include/PostServiceRemoteOptions.h new file mode 100644 index 000000000000..3e74a64ac10d --- /dev/null +++ b/Modules/Sources/WordPressKitObjC/include/PostServiceRemoteOptions.h @@ -0,0 +1,86 @@ +#import + +typedef NS_ENUM(NSUInteger, PostServiceResultsOrder) { + /** + (default) Request results in descending order. For dates, that means newest to oldest. + */ + PostServiceResultsOrderDescending = 0, + /** + Request results in ascending order. For dates, that means oldest to newest. + */ + PostServiceResultsOrderAscending +}; + +typedef NS_ENUM(NSUInteger, PostServiceResultsOrdering) { + /** + (default) Order the results by the created time of each post. + */ + PostServiceResultsOrderingByDate = 0, + /** + Order the results by the modified time of each post. + */ + PostServiceResultsOrderingByModified, + /** + Order the results lexicographically by the title of each post. + */ + PostServiceResultsOrderingByTitle, + /** + Order the results by the number of comments for each pot. + */ + PostServiceResultsOrderingByCommentCount, + /** + Order the results by the postID of each post. + */ + PostServiceResultsOrderingByPostID +}; + +@protocol PostServiceRemoteOptions + +/** + List of PostStatuses for which to query + */ +- (NSArray *)statuses; + +/** + The number of posts to return. Limit: 100. + */ +- (NSNumber *)number; + +/** + 0-indexed offset for paging requests. + */ +- (NSNumber *)offset; + +/** + The order direction of the results. + */ +- (PostServiceResultsOrder)order; + +/** + The ordering value used when ordering results. + */ +- (PostServiceResultsOrdering)orderBy; + +/** + Specify posts only by the given authorID. + @attention Not supported in XML-RPC. + */ +- (NSNumber *)authorID; + +/** + A search query used when requesting posts. + */ +- (NSString *)search; + +/** + The metadata to include in the returned results. + */ +- (NSString *)meta; + +/** + The tag to filter by. + */ +@optional +- (NSString *)tag; + +@end diff --git a/Modules/Sources/WordPressKitObjC/include/PostServiceRemoteREST.h b/Modules/Sources/WordPressKitObjC/include/PostServiceRemoteREST.h new file mode 100644 index 000000000000..d66533df41fd --- /dev/null +++ b/Modules/Sources/WordPressKitObjC/include/PostServiceRemoteREST.h @@ -0,0 +1,61 @@ +#import +#import "PostServiceRemote.h" +#import "SiteServiceRemoteWordPressComREST.h" +#import "RemoteMedia.h" + +@class RemoteUser; + +@interface PostServiceRemoteREST : SiteServiceRemoteWordPressComREST + +/** + * @brief Create a post remotely for the specified blog with a single piece of + * media. + * + * @discussion This purpose of this method is to give app extensions the ability to create a post + * with media in a single network operation. + * + * @param post The post to create remotely. Cannot be nil. + * @param media The post to create remotely. Can be nil. + * @param requestEnqueued The block that will be executed when the network request is queued. Can be nil. + * @param success The block that will be executed on success. Can be nil. + * @param failure The block that will be executed on failure. Can be nil. + */ +- (void)createPost:(RemotePost * _Nonnull)post + withMedia:(RemoteMedia * _Nullable)media + requestEnqueued:(void (^ _Nullable)(NSNumber * _Nonnull taskID))requestEnqueued + success:(void (^ _Nullable)(RemotePost * _Nullable))success + failure:(void (^ _Nullable)(NSError * _Nullable))failure; + +/** + * @brief Saves a post. + * + * + * @discussion Drafts and auto-drafts are just overwritten by autosave for the same + user if the post is not locked. + * Non drafts or other users drafts are not overwritten. + * @param post The post to save. Cannot be nil. + * @param success The block that will be executed on success. Can be nil. + * @param failure The block that will be executed on failure. Can be nil. + */ +- (void)autoSave:(RemotePost * _Nonnull)post + success:(void (^ _Nullable)(RemotePost * _Nullable post, NSString * _Nullable previewURL))success + failure:(void (^ _Nullable)(NSError * _Nullable error))failure; + +/** + * @brief Get autosave revision of a post. + * + * + * @discussion retrieve the latest autosave revision of a post + + * @param post The post to save. Cannot be nil. + * @param success The block that will be executed on success. Can be nil. + * @param failure The block that will be executed on failure. Can be nil. + */ +- (void)getAutoSaveForPost:(RemotePost * _Nonnull)post + success:(void (^ _Nullable)(RemotePost * _Nullable))success + failure:(void (^ _Nullable)(NSError * _Nullable error))failure; + +/// Returns a remote post with the given data. ++ (nonnull RemotePost *)remotePostFromJSONDictionary:(nonnull NSDictionary *)jsonPost; + +@end diff --git a/Modules/Sources/WordPressKitObjC/include/PostServiceRemoteXMLRPC.h b/Modules/Sources/WordPressKitObjC/include/PostServiceRemoteXMLRPC.h new file mode 100644 index 000000000000..3b56a493d512 --- /dev/null +++ b/Modules/Sources/WordPressKitObjC/include/PostServiceRemoteXMLRPC.h @@ -0,0 +1,9 @@ +#import +#import "PostServiceRemote.h" +#import "ServiceRemoteWordPressXMLRPC.h" + +@interface PostServiceRemoteXMLRPC : ServiceRemoteWordPressXMLRPC + ++ (RemotePost *)remotePostFromXMLRPCDictionary:(NSDictionary *)xmlrpcDictionary; + +@end diff --git a/Modules/Sources/WordPressKitObjC/include/ReaderPostServiceRemote.h b/Modules/Sources/WordPressKitObjC/include/ReaderPostServiceRemote.h new file mode 100644 index 000000000000..5986b446da2d --- /dev/null +++ b/Modules/Sources/WordPressKitObjC/include/ReaderPostServiceRemote.h @@ -0,0 +1,100 @@ +#import +#import "ServiceRemoteWordPressComREST.h" + +@class RemoteReaderPost; + +@interface ReaderPostServiceRemote : ServiceRemoteWordPressComREST + +/** + Fetches the posts from the specified remote endpoint + + @param algorithm meta data used in paging + @param count number of posts to fetch. + @param date the date to fetch posts before. + @param success block called on a successful fetch. + @param failure block called if there is any error. `error` can be any underlying network error. + */ +- (void)fetchPostsFromEndpoint:(NSURL *)endpoint + algorithm:(NSString *)algorithm + count:(NSUInteger)count + before:(NSDate *)date + success:(void (^)(NSArray *posts, NSString *algorithm))success + failure:(void (^)(NSError *error))failure; + +/** + Fetches the posts from the specified remote endpoint + + @param algorithm meta data used in paging + @param count number of posts to fetch. + @param offset The offset of the fetch. + @param success block called on a successful fetch. + @param failure block called if there is any error. `error` can be any underlying network error. + */ +- (void)fetchPostsFromEndpoint:(NSURL *)endpoint + algorithm:(NSString *)algorithm + count:(NSUInteger)count + offset:(NSUInteger)offset + success:(void (^)(NSArray *posts, NSString *algorithm))success + failure:(void (^)(NSError *))failure; + +/** + Fetches a specific post from the specified remote site + + @param postID the ID of the post to fetch + @param siteID the ID of the site the post belongs to + @param success block called on a successful fetch. + @param failure block called if there is any error. `error` can be any underlying network error. + */ +- (void)fetchPost:(NSUInteger)postID + fromSite:(NSUInteger)siteID + isFeed:(BOOL)isFeed + success:(void (^)(RemoteReaderPost *post))success + failure:(void (^)(NSError *error))failure; + +/** + Fetches a specific post from the specified URL + + @param postURL The URL of the post to fetch + @param success block called on a successful fetch. + @param failure block called if there is any error. `error` can be any underlying network error. + */ +- (void)fetchPostAtURL:(NSURL *)postURL + success:(void (^)(RemoteReaderPost *post))success + failure:(void (^)(NSError *error))failure; + +/** + Mark a post as liked by the user. + + @param postID The ID of the post. + @param siteID The ID of the site. + @param success block called on a successful fetch. + @param failure block called if there is any error. `error` can be any underlying network error. + */ +- (void)likePost:(NSUInteger)postID + forSite:(NSUInteger)siteID + success:(void (^)(void))success + failure:(void (^)(NSError *error))failure; + +/** + Mark a post as unliked by the user. + + @param postID The ID of the post. + @param siteID The ID of the site. + @param success block called on a successful fetch. + @param failure block called if there is any error. `error` can be any underlying network error. + */ +- (void)unlikePost:(NSUInteger)postID + forSite:(NSUInteger)siteID + success:(void (^)(void))success + failure:(void (^)(NSError *error))failure; + +/** + A helper method for constructing the endpoint URL for a reader search request. + + @param phrase The search phrase + + @return The endpoint URL as a string. + */ +- (NSString *)endpointUrlForSearchPhrase:(NSString *)phrase; + +@end diff --git a/Modules/Sources/WordPressKitObjC/include/ReaderSiteServiceRemote.h b/Modules/Sources/WordPressKitObjC/include/ReaderSiteServiceRemote.h new file mode 100644 index 000000000000..187d0190b73c --- /dev/null +++ b/Modules/Sources/WordPressKitObjC/include/ReaderSiteServiceRemote.h @@ -0,0 +1,126 @@ +#import +#import "ServiceRemoteWordPressComREST.h" + +typedef NS_ENUM(NSUInteger, ReaderSiteServiceRemoteError) { + ReaderSiteServiceRemoteInvalidHost, + ReaderSiteServiceRemoteUnsuccessfulFollowSite, + ReaderSiteServiceRemoteUnsuccessfulUnfollowSite, + ReaderSiteSErviceRemoteUnsuccessfulBlockSite +}; + +extern NSString * const ReaderSiteServiceRemoteErrorDomain; + +@interface ReaderSiteServiceRemote : ServiceRemoteWordPressComREST + +/** + Get a list of the sites the user follows. + + @param success block called on a successful fetch. + @param failure block called if there is any error. `error` can be any underlying network error. + */ +- (void)fetchFollowedSitesWithSuccess:(void(^)(NSArray *sites))success + failure:(void(^)(NSError *error))failure; + + +/** + Follow a wpcom site. + + @param siteID The ID of the site. + @param success block called on a successful follow. + @param failure block called if there is any error. `error` can be any underlying network error. + */ +- (void)followSiteWithID:(NSUInteger)siteID + success:(void(^)(void))success + failure:(void(^)(NSError *error))failure; + +/** + Unfollow a wpcom site + + @param siteID The ID of the site. + @param success block called on a successful unfollow. + @param failure block called if there is any error. `error` can be any underlying network error. + */ +- (void)unfollowSiteWithID:(NSUInteger)siteID + success:(void(^)(void))success + failure:(void(^)(NSError *error))failure; + +/** + Follow a wporg site. + + @param siteURL The URL of the site as a string. + @param success block called on a successful follow. + @param failure block called if there is any error. `error` can be any underlying network error. + */ +- (void)followSiteAtURL:(NSString *)siteURL + success:(void(^)(void))success + failure:(void(^)(NSError *error))failure; + +/** + Unfollow a wporg site + + @param siteURL The URL of the site as a string. + @param success block called on a successful unfollow. + @param failure block called if there is any error. `error` can be any underlying network error. + */ +- (void)unfollowSiteAtURL:(NSString *)siteURL + success:(void(^)(void))success + failure:(void(^)(NSError *error))failure; + +/** + Find the WordPress.com site ID for the site at the specified URL. + + @param siteURL the URL of the site. + @param success block called on a successful fetch. The found siteID is passed to the success block. + @param failure block called if there is any error. `error` can be any underlying network error. + */ +- (void)findSiteIDForURL:(NSURL *)siteURL + success:(void(^)(NSUInteger siteID))success + failure:(void(^)(NSError *error))failure; + +/** + Test a URL to see if a site exists. + + @param siteURL the URL of the site. + @param success block called on a successful request. + @param failure block called if there is any error. `error` can be any underlying network error. + */ +- (void)checkSiteExistsAtURL:(NSURL *)siteURL + success:(void (^)(void))success + failure:(void(^)(NSError *error))failure; + +/** + Check whether a site is already subscribed + + @param siteID The ID of the site. + @param success block called on a successful check. A boolean is returned indicating if the site is followed or not. + @param failure block called if there is any error. `error` can be any underlying network error. + */ +- (void)checkSubscribedToSiteByID:(NSUInteger)siteID + success:(void (^)(BOOL follows))success + failure:(void(^)(NSError *error))failure; + +/** + Check whether a feed is already subscribed + + @param siteURL the URL of the site. + @param success block called on a successful check. A boolean is returned indicating if the feed is followed or not. + @param failure block called if there is any error. `error` can be any underlying network error. + */ +- (void)checkSubscribedToFeedByURL:(NSURL *)siteURL + success:(void (^)(BOOL follows))success + failure:(void(^)(NSError *error))failure; + +/** + Block/unblock a site from showing its posts in the reader + + @param siteID The ID of the site (not feed). + @param blocked Boolean value. Yes if the site should be blocked. NO if the site should be unblocked. + @param success block called on a successful check. + @param failure block called if there is any error. `error` can be any underlying network error. + */ +- (void)flagSiteWithID:(NSUInteger)siteID + asBlocked:(BOOL)blocked + success:(void(^)(void))success + failure:(void(^)(NSError *error))failure; + +@end diff --git a/Modules/Sources/WordPressKitObjC/include/ReaderTopicServiceRemote.h b/Modules/Sources/WordPressKitObjC/include/ReaderTopicServiceRemote.h new file mode 100644 index 000000000000..cff92a655eb1 --- /dev/null +++ b/Modules/Sources/WordPressKitObjC/include/ReaderTopicServiceRemote.h @@ -0,0 +1,119 @@ +#import +#import "ServiceRemoteWordPressComREST.h" + +extern NSString * const WordPressComReaderEndpointURL; + +@class RemoteReaderSiteInfo; +@class RemoteReaderTopic; + +@interface ReaderTopicServiceRemote : ServiceRemoteWordPressComREST + +/** + Fetches the topics for the reader's menu from the remote service. + + @param success block called on a successful fetch. An `NSArray` of `NSDictionary` + objects describing topics is passed as an argument. + @param failure block called if there is any error. `error` can be any underlying network error. + */ +- (void)fetchReaderMenuWithSuccess:(void (^)(NSArray *topics))success + failure:(void (^)(NSError *error))failure; + +/** + Get a list of the sites the user follows with the default API parameters. + + @param success block called on a successful fetch. + @param failure block called if there is any error. `error` can be any underlying network error. + */ +- (void)fetchFollowedSitesWithSuccess:(void(^)(NSArray *sites))success + failure:(void(^)(NSError *error))failure DEPRECATED_MSG_ATTRIBUTE("Use fetchFollowedSitesForPage:number:success:failure: instead."); + +/** + Get a list of the sites the user follows with the specified API parameters. + + @param page The page number to fetch. + @param number The number of sites to fetch per page. + @param success block called on a successful fetch. + @param failure block called if there is any error. `error` can be any underlying network error. + */ +- (void)fetchFollowedSitesForPage:(NSUInteger)page + number:(NSUInteger)number + success:(void(^)(NSNumber *totalSites, NSArray *sites))success + failure:(void(^)(NSError *error))failure; + +/** + Unfollows the topic with the specified slug. + + @param slug The slug of the topic to unfollow. + @param success block called on a successful fetch. An `NSArray` of `NSDictionary` + objects describing topics is passed as an argument. + @param failure block called if there is any error. `error` can be any underlying network error. + */ +- (void)unfollowTopicWithSlug:(NSString *)slug + withSuccess:(void (^)(NSNumber *topicID))success + failure:(void (^)(NSError *error))failure; + +/** + Follows the topic with the specified name. + + @param topicName The name of the topic to follow. + @param success block called on a successful fetch. An `NSArray` of `NSDictionary` + objects describing topics is passed as an argument. + @param failure block called if there is any error. `error` can be any underlying network error. + */ +- (void)followTopicNamed:(NSString *)topicName + withSuccess:(void (^)(NSNumber *topicID))success + failure:(void (^)(NSError *error))failure; + +/** + Follows the topic with the specified slug. + + @param slug The slug of the topic to follow. + @param success block called on a successful fetch. An `NSArray` of `NSDictionary` + objects describing topics is passed as an argument. + @param failure block called if there is any error. `error` can be any underlying network error. + */ +- (void)followTopicWithSlug:(NSString *)slug + withSuccess:(void (^)(NSNumber *topicID))success + failure:(void (^)(NSError *error))failure; + +/** + Fetches public information about the tag with the specified slug. + + @param slug The slug of the topic. + @param success block called on a successful fetch. An instance of RemoteReaderTopic + is passed to the callback block. + @param failure block called if there is any error. `error` can be any underlying network error. + */ +- (void)fetchTagInfoForTagWithSlug:(NSString *)slug + success:(void (^)(RemoteReaderTopic *remoteTopic))success + failure:(void (^)(NSError *error))failure; + +/** + Fetches public information about the site with the specified ID. + + @param siteID The ID of the site. + @param isFeed If the site is a feed. + @param success block called on a successful fetch. An instance of RemoteReaderSiteInfo + is passed to the callback block. + @param failure block called if there is any error. `error` can be any underlying network error. + */ +- (void)fetchSiteInfoForSiteWithID:(NSNumber *)siteID + isFeed:(BOOL)isFeed + success:(void (^)(RemoteReaderSiteInfo *siteInfo))success + failure:(void (^)(NSError *error))failure; + +/** + Takes a topic name and santitizes it, returning what *should* be its slug. + + @param topicName The natural language name of a topic. + + @return The sanitized name, as a topic slug. + */ +- (NSString *)slugForTopicName:(NSString *)topicName; + +/** + Returns a REST URL string for an endpoint path + @param path A partial path for the API call + */ +- (NSString *)endpointUrlForPath:(NSString *)path; +@end diff --git a/Modules/Sources/WordPressKitObjC/include/RemoteComment.h b/Modules/Sources/WordPressKitObjC/include/RemoteComment.h new file mode 100644 index 000000000000..82c1917c7750 --- /dev/null +++ b/Modules/Sources/WordPressKitObjC/include/RemoteComment.h @@ -0,0 +1,23 @@ +#import + +@interface RemoteComment : NSObject +@property (nonatomic, strong) NSNumber *commentID; +@property (nonatomic, strong) NSNumber *authorID; +@property (nonatomic, strong) NSString *author; +@property (nonatomic, strong) NSString *authorEmail; +@property (nonatomic, strong) NSString *authorUrl; +@property (nonatomic, strong) NSString *authorAvatarURL; +@property (nonatomic, strong) NSString *authorIP; +@property (nonatomic, strong) NSString *content; +@property (nonatomic, strong) NSString *rawContent; +@property (nonatomic, strong) NSDate *date; +@property (nonatomic, strong) NSString *link; +@property (nonatomic, strong) NSNumber *parentID; +@property (nonatomic, strong) NSNumber *postID; +@property (nonatomic, strong) NSString *postTitle; +@property (nonatomic, strong) NSString *status; +@property (nonatomic, strong) NSString *type; +@property (nonatomic) BOOL isLiked; +@property (nonatomic, strong) NSNumber *likeCount; +@property (nonatomic) BOOL canModerate; +@end diff --git a/Modules/Sources/WordPressKitObjC/include/RemoteMedia.h b/Modules/Sources/WordPressKitObjC/include/RemoteMedia.h new file mode 100644 index 000000000000..a5f02f317c5d --- /dev/null +++ b/Modules/Sources/WordPressKitObjC/include/RemoteMedia.h @@ -0,0 +1,28 @@ +#import + +@interface RemoteMedia : NSObject + +@property (nonatomic, strong, nullable) NSNumber *mediaID; +@property (nonatomic, strong, nullable) NSURL *url; +@property (nonatomic, strong, nullable) NSURL *localURL; +@property (nonatomic, strong, nullable) NSURL *largeURL; +@property (nonatomic, strong, nullable) NSURL *mediumURL; +@property (nonatomic, strong, nullable) NSURL *guid; +@property (nonatomic, strong, nullable) NSDate *date; +@property (nonatomic, strong, nullable) NSNumber *postID; +@property (nonatomic, strong, nullable) NSString *file; +@property (nonatomic, strong, nullable) NSString *mimeType; +@property (nonatomic, strong, nullable) NSString *extension; +@property (nonatomic, strong, nullable) NSString *title; +@property (nonatomic, strong, nullable) NSString *caption; +@property (nonatomic, strong, nullable) NSString *descriptionText; +@property (nonatomic, strong, nullable) NSString *alt; +@property (nonatomic, strong, nullable) NSNumber *height; +@property (nonatomic, strong, nullable) NSNumber *width; +@property (nonatomic, strong, nullable) NSString *shortcode; +@property (nonatomic, strong, nullable) NSDictionary *exif; +@property (nonatomic, strong, nullable) NSString *videopressGUID; +@property (nonatomic, strong, nullable) NSNumber *length; +@property (nonatomic, strong, nullable) NSString *remoteThumbnailURL; + +@end diff --git a/Modules/Sources/WordPressKitObjC/include/RemotePost.h b/Modules/Sources/WordPressKitObjC/include/RemotePost.h new file mode 100644 index 000000000000..e74cee04301d --- /dev/null +++ b/Modules/Sources/WordPressKitObjC/include/RemotePost.h @@ -0,0 +1,67 @@ +#import +@class RemotePostAutosave; + +extern NSString * const PostStatusDraft; +extern NSString * const PostStatusPending; +extern NSString * const PostStatusPrivate; +extern NSString * const PostStatusPublish; +extern NSString * const PostStatusScheduled; +extern NSString * const PostStatusTrash; +extern NSString * const PostStatusDeleted; + +/// Represents the response object for APIs that create or update posts. +@interface RemotePost : NSObject +- (id)initWithSiteID:(NSNumber *)siteID status:(NSString *)status title:(NSString *)title content:(NSString *)content; + +@property (nonatomic, strong) NSNumber *postID; +@property (nonatomic, strong) NSNumber *siteID; +@property (nonatomic, strong) NSString *authorAvatarURL; +@property (nonatomic, strong) NSString *authorDisplayName; +@property (nonatomic, strong) NSString *authorEmail; +@property (nonatomic, strong) NSString *authorURL; +@property (nonatomic, strong) NSNumber *authorID; +@property (nonatomic, strong) NSDate *date; +@property (nonatomic, strong) NSDate *dateModified; +@property (nonatomic, strong) NSString *title; +@property (nonatomic, strong) NSURL *URL; +@property (nonatomic, strong) NSURL *shortURL; +@property (nonatomic, strong) NSString *content; +@property (nonatomic, strong) NSString *excerpt; +@property (nonatomic, strong) NSString *slug; +@property (nonatomic, strong) NSString *suggestedSlug; +@property (nonatomic, strong) NSString *status; +@property (nonatomic, strong) NSString *password; +@property (nonatomic, strong) NSNumber *parentID; +@property (nonatomic, strong) NSNumber *postThumbnailID; +@property (nonatomic, strong) NSString *postThumbnailPath; +@property (nonatomic, strong) NSString *type; +@property (nonatomic, strong) NSString *format; +@property (nonatomic, assign) NSInteger order; + +/** +* A snapshot of the post at the last autosave. +* +* This is nullable. +*/ +@property (nonatomic, strong) RemotePostAutosave *autosave; + +@property (nonatomic, strong) NSNumber *commentCount; +@property (nonatomic, strong) NSNumber *likeCount; + +@property (nonatomic, strong) NSArray *categories; +@property (nonatomic, strong) NSArray *revisions; +@property (nonatomic, strong) NSArray *tags; +@property (nonatomic, strong) NSString *pathForDisplayImage; +@property (nonatomic, assign) NSNumber *isStickyPost; +@property (nonatomic, assign) BOOL isFeaturedImageChanged; + +/** + Array of custom fields. Each value is a dictionary containing {ID, key, value} + */ +@property (nonatomic, strong) NSArray *metadata; + +// Featured images? +// Geolocation? +// Attachments? +// Metadata? +@end diff --git a/Modules/Sources/WordPressKitObjC/include/RemotePostCategory.h b/Modules/Sources/WordPressKitObjC/include/RemotePostCategory.h new file mode 100644 index 000000000000..21f189e98e70 --- /dev/null +++ b/Modules/Sources/WordPressKitObjC/include/RemotePostCategory.h @@ -0,0 +1,7 @@ +#import + +@interface RemotePostCategory : NSObject +@property (nonatomic, strong) NSNumber *categoryID; +@property (nonatomic, strong) NSString *name; +@property (nonatomic, strong) NSNumber *parentID; +@end diff --git a/Modules/Sources/WordPressKitObjC/include/RemotePostTag.h b/Modules/Sources/WordPressKitObjC/include/RemotePostTag.h new file mode 100644 index 000000000000..e6a84f5957e4 --- /dev/null +++ b/Modules/Sources/WordPressKitObjC/include/RemotePostTag.h @@ -0,0 +1,11 @@ +#import + +@interface RemotePostTag : NSObject + +@property (nonatomic, strong) NSNumber *tagID; +@property (nonatomic, strong) NSString *name; +@property (nonatomic, strong) NSString *slug; +@property (nonatomic, strong) NSString *tagDescription; +@property (nonatomic, strong) NSNumber *postCount; + +@end diff --git a/Modules/Sources/WordPressKitObjC/include/RemotePostType.h b/Modules/Sources/WordPressKitObjC/include/RemotePostType.h new file mode 100644 index 000000000000..895e91178cb2 --- /dev/null +++ b/Modules/Sources/WordPressKitObjC/include/RemotePostType.h @@ -0,0 +1,9 @@ +#import + +@interface RemotePostType : NSObject + +@property (nonatomic, strong) NSNumber *apiQueryable; +@property (nonatomic, strong) NSString *name; +@property (nonatomic, strong) NSString *label; + +@end diff --git a/Modules/Sources/WordPressKitObjC/include/RemoteReaderPost.h b/Modules/Sources/WordPressKitObjC/include/RemoteReaderPost.h new file mode 100644 index 000000000000..fa623cdc3e69 --- /dev/null +++ b/Modules/Sources/WordPressKitObjC/include/RemoteReaderPost.h @@ -0,0 +1,72 @@ +#import + +@class RemoteSourcePostAttribution; +@class RemoteReaderCrossPostMeta; + +@interface RemoteReaderPost : NSObject + +// Reader Post Model +@property (nonatomic, strong) NSString *authorAvatarURL; +@property (nonatomic, strong) NSString *authorDisplayName; +@property (nonatomic, strong) NSString *authorEmail; +@property (nonatomic, strong) NSString *authorURL; +@property (nonatomic, strong) NSString *siteIconURL; +@property (nonatomic, strong) NSString *blogName; +@property (nonatomic, strong) NSString *blogDescription; +@property (nonatomic, strong) NSString *blogURL; +@property (nonatomic, strong) NSNumber *commentCount; +@property (nonatomic) BOOL commentsOpen; +@property (nonatomic, strong) NSString *featuredImage; +@property (nonatomic, strong) NSString *autoSuggestedFeaturedImage; +@property (nonatomic, strong) NSString *suitableImageFromPostContent; +@property (nonatomic, strong) NSNumber *feedID; +@property (nonatomic, strong) NSNumber *feedItemID; +@property (nonatomic, strong) NSString *globalID; +@property (nonatomic, strong) NSNumber *organizationID; +@property (nonatomic) BOOL isBlogAtomic; +@property (nonatomic) BOOL isBlogPrivate; +@property (nonatomic) BOOL isFollowing; +@property (nonatomic) BOOL isLiked; +@property (nonatomic) BOOL isReblogged; +@property (nonatomic) BOOL isWPCom; +@property (nonatomic) BOOL isSeen; +@property (nonatomic) BOOL isSeenSupported; +@property (nonatomic, strong) NSNumber *likeCount; +@property (nonatomic, strong) NSNumber *score; +@property (nonatomic, strong) NSNumber *siteID; +@property (nonatomic, strong) NSDate *sortDate; +@property (nonatomic, strong) NSNumber *sortRank; +@property (nonatomic, strong) NSString *summary; +@property (nonatomic, strong) NSString *tags; +@property (nonatomic) BOOL isLikesEnabled; +@property (nonatomic) BOOL isSharingEnabled; +@property (nonatomic, strong) RemoteSourcePostAttribution *sourceAttribution; +@property (nonatomic, strong) RemoteReaderCrossPostMeta *crossPostMeta; + +@property (nonatomic, strong) NSString *primaryTag; +@property (nonatomic, strong) NSString *primaryTagSlug; +@property (nonatomic, strong) NSString *secondaryTag; +@property (nonatomic, strong) NSString *secondaryTagSlug; +@property (nonatomic) BOOL isExternal; +@property (nonatomic) BOOL isJetpack; +@property (nonatomic) NSNumber *wordCount; +@property (nonatomic) NSNumber *readingTime; +@property (nonatomic, strong) NSString *railcar; + +@property (nonatomic) BOOL canSubscribeComments; +@property (nonatomic) BOOL isSubscribedComments; +@property (nonatomic) BOOL receivesCommentNotifications; + +// Base Post Model +@property (nonatomic, strong) NSNumber *authorID; +@property (nonatomic, strong) NSString *author; +@property (nonatomic, strong) NSString *content; +@property (nonatomic, strong) NSString *date_created_gmt; +@property (nonatomic, strong) NSString *permalink; +@property (nonatomic, strong) NSNumber *postID; +@property (nonatomic, strong) NSString *postTitle; +@property (nonatomic, strong) NSString *status; + +- (instancetype)initWithDictionary:(NSDictionary *)dict; + +@end diff --git a/Modules/Sources/WordPressKitObjC/include/RemoteSourcePostAttribution.h b/Modules/Sources/WordPressKitObjC/include/RemoteSourcePostAttribution.h new file mode 100644 index 000000000000..52cea5a65b93 --- /dev/null +++ b/Modules/Sources/WordPressKitObjC/include/RemoteSourcePostAttribution.h @@ -0,0 +1,17 @@ +#import + +@interface RemoteSourcePostAttribution : NSObject + +@property (nonatomic, strong) NSString *permalink; +@property (nonatomic, strong) NSString *authorName; +@property (nonatomic, strong) NSString *authorURL; +@property (nonatomic, strong) NSString *blogName; +@property (nonatomic, strong) NSString *blogURL; +@property (nonatomic, strong) NSString *avatarURL; +@property (nonatomic, strong) NSNumber *blogID; +@property (nonatomic, strong) NSNumber *postID; +@property (nonatomic, strong) NSNumber *likeCount; +@property (nonatomic, strong) NSNumber *commentCount; +@property (nonatomic, strong) NSArray *taxonomies; + +@end diff --git a/Modules/Sources/WordPressKitObjC/include/RemoteTaxonomyPaging.h b/Modules/Sources/WordPressKitObjC/include/RemoteTaxonomyPaging.h new file mode 100644 index 000000000000..8b216f81ce50 --- /dev/null +++ b/Modules/Sources/WordPressKitObjC/include/RemoteTaxonomyPaging.h @@ -0,0 +1,53 @@ +#import + +typedef NS_ENUM(NSUInteger, RemoteTaxonomyPagingResultsOrder) { + RemoteTaxonomyPagingOrderAscending = 0, + RemoteTaxonomyPagingOrderDescending +}; + +typedef NS_ENUM(NSUInteger, RemoteTaxonomyPagingResultsOrdering) { + /* Order the results by the name of the taxonomy. + */ + RemoteTaxonomyPagingResultsOrderingByName = 0, + /* Order the results by the number of posts associated with the taxonomy. + */ + RemoteTaxonomyPagingResultsOrderingByCount +}; + + +/** + @class RemoteTaxonomyPaging + @brief A paging object for passing parameters to the API when requesting paged lists of taxonomies. + See each remote API for specifics regarding default values and limits. + WP.com/REST Jetpack: https://developer.wordpress.com/docs/api/1.1/get/sites/%24site/categories/ + XML-RPC: https://codex.wordpress.org/XML-RPC_WordPress_API/Taxonomies + */ +@interface RemoteTaxonomyPaging : NSObject + +/** + @brief The max number of taxonomies to return. + */ +@property (nonatomic, strong) NSNumber *number; + +/** + @brief 0-indexed offset for paging. + */ +@property (nonatomic, strong) NSNumber *offset; + +/** + @brief Return the Nth 1-indexed page of tags. Takes precedence over the offset parameter. + @attention Not supported in XML-RPC. + */ +@property (nonatomic, strong) NSNumber *page; + +/** + @brief Return the taxonomies in ascending or descending order. Defaults YES via the API. + */ +@property (nonatomic, assign) RemoteTaxonomyPagingResultsOrder order; + +/** + @brief Return the taxonomies ordering by name or associated count. + */ +@property (nonatomic, assign) RemoteTaxonomyPagingResultsOrdering orderBy; + +@end diff --git a/Modules/Sources/WordPressKitObjC/include/RemoteTheme.h b/Modules/Sources/WordPressKitObjC/include/RemoteTheme.h new file mode 100644 index 000000000000..c067d9adb18d --- /dev/null +++ b/Modules/Sources/WordPressKitObjC/include/RemoteTheme.h @@ -0,0 +1,26 @@ +#import + +@interface RemoteTheme : NSObject + +@property (nonatomic, assign) BOOL active; +@property (nonatomic, strong) NSString *type; +@property (nonatomic, strong) NSString *author; +@property (nonatomic, strong) NSString *authorUrl; +@property (nonatomic, strong) NSString *desc; +@property (nonatomic, strong) NSString *demoUrl; +@property (nonatomic, strong) NSString *themeUrl; +@property (nonatomic, strong) NSString *downloadUrl; +@property (nonatomic, strong) NSDate *launchDate; +@property (nonatomic, strong) NSString *name; +@property (nonatomic, assign) NSInteger order; +@property (nonatomic, strong) NSNumber *popularityRank; +@property (nonatomic, strong) NSString *previewUrl; +@property (nonatomic, strong) NSString *price; +@property (nonatomic, strong) NSNumber *purchased; +@property (nonatomic, strong) NSString *screenshotUrl; +@property (nonatomic, strong) NSString *stylesheet; +@property (nonatomic, strong) NSString *themeId; +@property (nonatomic, strong) NSNumber *trendingRank; +@property (nonatomic, strong) NSString *version; + +@end diff --git a/Modules/Sources/WordPressKitObjC/include/ServiceRemoteWordPressComREST.h b/Modules/Sources/WordPressKitObjC/include/ServiceRemoteWordPressComREST.h new file mode 100644 index 000000000000..133a1e24a660 --- /dev/null +++ b/Modules/Sources/WordPressKitObjC/include/ServiceRemoteWordPressComREST.h @@ -0,0 +1,44 @@ +#import +#import "WordPressComRESTAPIInterfacing.h" +#import "WordPressComRESTAPIVersion.h" + +NS_ASSUME_NONNULL_BEGIN + +/** + * @class ServiceRemoteREST + * @brief Parent class for all REST service classes. + */ +@interface ServiceRemoteWordPressComREST : NSObject + +/** + * @brief The interface to the WordPress.com API to use for performing REST requests. + * This is meant to gradually replace `wordPressComRestApi`. + */ +@property (nonatomic, strong, readonly) id wordPressComRESTAPI; + +/** + * @brief Designated initializer. + * + * @param api The API to use for communication. Cannot be nil. + * + * @returns The initialized object. + */ +- (instancetype)initWithWordPressComRestApi:(id)api; + +#pragma mark - Request URL construction + +/** + * @brief Constructs the request URL for the specified API version and specified resource URL. + * + * @param endpoint The URL of the resource for the request. Cannot be nil. + * @param apiVersion The version of the API to use. + * + * @returns The request URL. + */ +- (NSString *)pathForEndpoint:(NSString *)endpoint + withVersion:(WordPressComRESTAPIVersion)apiVersion +NS_SWIFT_NAME(path(forEndpoint:withVersion:)); + +@end + +NS_ASSUME_NONNULL_END diff --git a/Modules/Sources/WordPressKitObjC/include/ServiceRemoteWordPressXMLRPC.h b/Modules/Sources/WordPressKitObjC/include/ServiceRemoteWordPressXMLRPC.h new file mode 100644 index 000000000000..8b740c6b7a62 --- /dev/null +++ b/Modules/Sources/WordPressKitObjC/include/ServiceRemoteWordPressXMLRPC.h @@ -0,0 +1,18 @@ +#import +#import "WordPressOrgXMLRPCApiInterfacing.h" + +NS_ASSUME_NONNULL_BEGIN + +@interface ServiceRemoteWordPressXMLRPC : NSObject + +- (id)initWithApi:(id)api username:(NSString *)username password:(NSString *)password; + +@property (nonatomic, readonly) id api; + +- (NSArray *)defaultXMLRPCArguments; +- (NSArray *)XMLRPCArgumentsWithExtra:(_Nullable id)extra; +- (NSArray *)XMLRPCArgumentsWithExtraDefaults:(NSArray *)extraDefaults andExtra:(_Nullable id)extra; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Modules/Sources/WordPressKitObjC/include/SiteServiceRemoteWordPressComREST.h b/Modules/Sources/WordPressKitObjC/include/SiteServiceRemoteWordPressComREST.h new file mode 100644 index 000000000000..a6bcf5810bfa --- /dev/null +++ b/Modules/Sources/WordPressKitObjC/include/SiteServiceRemoteWordPressComREST.h @@ -0,0 +1,15 @@ +#import +#import "ServiceRemoteWordPressComREST.h" + +NS_ASSUME_NONNULL_BEGIN + +@interface SiteServiceRemoteWordPressComREST : ServiceRemoteWordPressComREST + +@property (nonatomic, readonly) NSNumber *siteID; + +- (instancetype)initWithWordPressComRestApi:(id)api __unavailable; +- (instancetype)initWithWordPressComRestApi:(id)api siteID:(NSNumber *)siteID; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Modules/Sources/WordPressKitObjC/include/TaxonomyServiceRemote.h b/Modules/Sources/WordPressKitObjC/include/TaxonomyServiceRemote.h new file mode 100644 index 000000000000..be7466b6eacb --- /dev/null +++ b/Modules/Sources/WordPressKitObjC/include/TaxonomyServiceRemote.h @@ -0,0 +1,86 @@ +#import + +NS_ASSUME_NONNULL_BEGIN + +@class RemotePostCategory; +@class RemotePostTag; +@class RemoteTaxonomyPaging; + +/** + Interface for requesting taxonomy such as tags and categories on a site. + */ +@protocol TaxonomyServiceRemote + +/** + Create a new category with the site. + */ +- (void)createCategory:(RemotePostCategory *)category + success:(nullable void (^)(RemotePostCategory *category))success + failure:(nullable void (^)(NSError *error))failure; + +/** + Fetch a list of categories associated with the site. + Note: Requests no paging parameters via the API defaulting the response. + */ +- (void)getCategoriesWithSuccess:(void (^)(NSArray *categories))success + failure:(nullable void (^)(NSError *error))failure; + +/** + Fetch a list of categories associated with the site with paging. + */ +- (void)getCategoriesWithPaging:(RemoteTaxonomyPaging *)paging + success:(void (^)(NSArray *categories))success + failure:(nullable void (^)(NSError *error))failure; + +/** + Fetch a list of categories whose names or slugs match the provided search query. Case-insensitive. + */ +- (void)searchCategoriesWithName:(NSString *)nameQuery + success:(void (^)(NSArray *categories))success + failure:(nullable void (^)(NSError *error))failure; + +/** + Create a new tag with the site. + */ +- (void)createTag:(RemotePostTag *)tag + success:(nullable void (^)(RemotePostTag *tag))success + failure:(nullable void (^)(NSError *error))failure; + +/** + Update a tag with the site. + */ +- (void)updateTag:(RemotePostTag *)tag + success:(nullable void (^)(RemotePostTag *tag))success + failure:(nullable void (^)(NSError *error))failure; + +/** + Delete a tag with the site. + */ +- (void)deleteTag:(RemotePostTag *)tag + success:(nullable void (^)(void))success + failure:(nullable void (^)(NSError *error))failure; + +/** + Fetch a list of tags associated with the site. + Note: Requests no paging parameters via the API defaulting the response. + */ +- (void)getTagsWithSuccess:(void (^)(NSArray *tags))success + failure:(nullable void (^)(NSError *error))failure; + +/** + Fetch a list of tags associated with the site with paging. + */ +- (void)getTagsWithPaging:(RemoteTaxonomyPaging *)paging + success:(void (^)(NSArray *tags))success + failure:(nullable void (^)(NSError *error))failure; + +/** + Fetch a list of tags whose names or slugs match the provided search query. Case-insensitive. + */ +- (void)searchTagsWithName:(NSString *)nameQuery + success:(void (^)(NSArray *tags))success + failure:(nullable void (^)(NSError *error))failure; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Modules/Sources/WordPressKitObjC/include/TaxonomyServiceRemoteREST.h b/Modules/Sources/WordPressKitObjC/include/TaxonomyServiceRemoteREST.h new file mode 100644 index 000000000000..62512af7b8bc --- /dev/null +++ b/Modules/Sources/WordPressKitObjC/include/TaxonomyServiceRemoteREST.h @@ -0,0 +1,7 @@ +#import +#import "TaxonomyServiceRemote.h" +#import "SiteServiceRemoteWordPressComREST.h" + +@interface TaxonomyServiceRemoteREST : SiteServiceRemoteWordPressComREST + +@end diff --git a/Modules/Sources/WordPressKitObjC/include/TaxonomyServiceRemoteXMLRPC.h b/Modules/Sources/WordPressKitObjC/include/TaxonomyServiceRemoteXMLRPC.h new file mode 100644 index 000000000000..3d234ccb5e6b --- /dev/null +++ b/Modules/Sources/WordPressKitObjC/include/TaxonomyServiceRemoteXMLRPC.h @@ -0,0 +1,9 @@ +#import +#import "TaxonomyServiceRemote.h" +#import "ServiceRemoteWordPressXMLRPC.h" + +@class RemoteCategory; + +@interface TaxonomyServiceRemoteXMLRPC : ServiceRemoteWordPressXMLRPC + +@end diff --git a/Modules/Sources/WordPressKitObjC/include/ThemeServiceRemote.h b/Modules/Sources/WordPressKitObjC/include/ThemeServiceRemote.h new file mode 100644 index 000000000000..bf0a0af25dc3 --- /dev/null +++ b/Modules/Sources/WordPressKitObjC/include/ThemeServiceRemote.h @@ -0,0 +1,158 @@ +#import +#import "ServiceRemoteWordPressComREST.h" + +@class Blog; +@class RemoteTheme; + +typedef void(^ThemeServiceRemoteSuccessBlock)(void); +typedef void(^ThemeServiceRemoteThemeRequestSuccessBlock)(RemoteTheme *theme); +typedef void(^ThemeServiceRemoteThemesRequestSuccessBlock)(NSArray *themes, BOOL hasMore, NSInteger totalThemeCount); +typedef void(^ThemeServiceRemoteThemeIdentifiersRequestSuccessBlock)(NSArray *themeIdentifiers); +typedef void(^ThemeServiceRemoteFailureBlock)(NSError *error); + +@interface ThemeServiceRemote : ServiceRemoteWordPressComREST + +#pragma mark - Getting themes + +/** + * @brief Gets the active theme for a specific blog. + * + * @param blogId The ID of the blog to get the active theme for. Cannot be nil. + * @param success The success handler. Can be nil. + * @param failure The failure handler. Can be nil. + * + * @returns A progress object that can be used to track progress and/or cancel the task + */ +- (NSProgress *)getActiveThemeForBlogId:(NSNumber *)blogId + success:(ThemeServiceRemoteThemeRequestSuccessBlock)success + failure:(ThemeServiceRemoteFailureBlock)failure; + +/** + * @brief Gets the list of purchased-theme-identifiers for a blog. + * + * @param blogId The ID of the blog to get the themes for. Cannot be nil. + * @param success The success handler. Can be nil. + * @param failure The failure handler. Can be nil. + * + * @returns A progress object that can be used to track progress and/or cancel the task + */ +- (NSProgress *)getPurchasedThemesForBlogId:(NSNumber *)blogId + success:(ThemeServiceRemoteThemeIdentifiersRequestSuccessBlock)success + failure:(ThemeServiceRemoteFailureBlock)failure; + +/** + * @brief Gets information for a specific theme. + * + * @param themeId The identifier of the theme to request info for. Cannot be nil. + * @param success The success handler. Can be nil. + * @param failure The failure handler. Can be nil. + * + * @returns A progress object that can be used to track progress and/or cancel the task + */ +- (NSProgress *)getThemeId:(NSString*)themeId + success:(ThemeServiceRemoteThemeRequestSuccessBlock)success + failure:(ThemeServiceRemoteFailureBlock)failure; + +/** + * @brief Gets the list of WP.com available themes. + * @details Includes premium themes even if not purchased. Don't call this method if the list + * you want to retrieve is for a specific blog. Use getThemesForBlogId instead. + * + * @param search Search term for filtering themes. Cannot be nil. + * @param freeOnly Only fetch free themes, if false all WP themes will be returned + * @param page Results page to return. + * @param success The success handler. Can be nil. + * @param failure The failure handler. Can be nil. + * + * @returns A progress object that can be used to track progress and/or cancel the task + */ +- (NSProgress *)getWPThemesPage:(NSInteger)page + search:(NSString *)search + freeOnly:(BOOL)freeOnly + success:(ThemeServiceRemoteThemesRequestSuccessBlock)success + failure:(ThemeServiceRemoteFailureBlock)failure; + +/** + * @brief Gets the list of available themes for a blog. + * @details Includes premium themes even if not purchased. The only difference with the + * regular getThemes method is that legacy themes that are no longer available to new + * blogs, can be accessible for older blogs through this call. This means that + * whenever we need to show the list of themes a blog can use, we should be calling + * this method and not getThemes. + * + * @param blogId The ID of the blog to get the themes for. Cannot be nil. + * @param page Results page to return. + * @param success The success handler. Can be nil. + * @param failure The failure handler. Can be nil. + * + * @returns A progress object that can be used to track progress and/or cancel the task + */ +- (NSProgress *)getThemesForBlogId:(NSNumber *)blogId + page:(NSInteger)page + success:(ThemeServiceRemoteThemesRequestSuccessBlock)success + failure:(ThemeServiceRemoteFailureBlock)failure; + +/** + * @brief Gets the list of available custom themes for a blog. + * @details To be used with Jetpack sites, it returns the list of themes uploaded to the site. + * + * @param blogId The ID of the blog to get the themes for. Cannot be nil. + * @param success The success handler. Can be nil. + * @param failure The failure handler. Can be nil. + * + * @returns A progress object that can be used to track progress and/or cancel the task + */ +- (NSProgress *)getCustomThemesForBlogId:(NSNumber *)blogId + success:(ThemeServiceRemoteThemesRequestSuccessBlock)success + failure:(ThemeServiceRemoteFailureBlock)failure; + +/** + * @brief Gets a list of suggested starter themes for the given site category + * (blog, website, portfolio). + * @details During the site creation process, a list of suggested mobile-friendly starter + * themes is displayed for the selected category. + * + * @param category The category for the site being created. Cannot be nil. + * @param page Results page to return. Cannot be nil. + * @param success The success handler. Can be nil. + * @param failure The failure handler. Can be nil. + */ +- (void)getStartingThemesForCategory:(NSString *)category + page:(NSInteger)page + success:(ThemeServiceRemoteThemesRequestSuccessBlock)success + failure:(ThemeServiceRemoteFailureBlock)failure; + +#pragma mark - Activating themes + +/** + * @brief Activates the specified theme for the specified blog. + * + * @param themeId The ID of the theme to activate. Cannot be nil. + * @param blogId The ID of the target blog. Cannot be nil. + * @param success The success handler. Can be nil. + * @param failure The failure handler. Can be nil. + * + * @returns A progress object that can be used to track progress and/or cancel the task + */ +- (NSProgress *)activateThemeId:(NSString*)themeId + forBlogId:(NSNumber *)blogId + success:(ThemeServiceRemoteThemeRequestSuccessBlock)success + failure:(ThemeServiceRemoteFailureBlock)failure; + + +/** + * @brief Installs the specified theme on the specified Jetpack blog. + * + * @param themeId The ID of the theme to install. Cannot be nil. + * @param blogId The ID of the target blog. Cannot be nil. + * @param success The success handler. Can be nil. + * @param failure The failure handler. Can be nil. + * + * @returns A progress object that can be used to track progress and/or cancel the task + */ +- (NSProgress *)installThemeId:(NSString*)themeId + forBlogId:(NSNumber *)blogId + success:(ThemeServiceRemoteThemeRequestSuccessBlock)success + failure:(ThemeServiceRemoteFailureBlock)failure; + +@end diff --git a/Modules/Sources/WordPressKitObjC/include/WPKitDateUtils.h b/Modules/Sources/WordPressKitObjC/include/WPKitDateUtils.h new file mode 100644 index 000000000000..4cd6a337258b --- /dev/null +++ b/Modules/Sources/WordPressKitObjC/include/WPKitDateUtils.h @@ -0,0 +1,8 @@ +#import + +@interface WPKitDateUtils : NSObject + ++ (NSDate *)dateFromISOString:(NSString *)isoString; ++ (NSString *)isoStringFromDate:(NSDate *)date; + +@end diff --git a/Modules/Sources/WordPressKitObjC/include/WPKitLogging.h b/Modules/Sources/WordPressKitObjC/include/WPKitLogging.h new file mode 100644 index 000000000000..76ed27760681 --- /dev/null +++ b/Modules/Sources/WordPressKitObjC/include/WPKitLogging.h @@ -0,0 +1,30 @@ +#import + +NS_ASSUME_NONNULL_BEGIN + +@protocol WordPressKitLoggingDelegate + +- (void)logError:(NSString *)str; +- (void)logWarning:(NSString *)str; +- (void)logInfo:(NSString *)str; +- (void)logDebug:(NSString *)str; +- (void)logVerbose:(NSString *)str; + +@end + +FOUNDATION_EXTERN id _Nullable WPKitGetLoggingDelegate(void); +FOUNDATION_EXTERN void WPKitSetLoggingDelegate(id _Nullable logger); + +FOUNDATION_EXTERN void WPKitLogError(NSString *str, ...) NS_FORMAT_FUNCTION(1, 2); +FOUNDATION_EXTERN void WPKitLogWarning(NSString *str, ...) NS_FORMAT_FUNCTION(1, 2); +FOUNDATION_EXTERN void WPKitLogInfo(NSString *str, ...) NS_FORMAT_FUNCTION(1, 2); +FOUNDATION_EXTERN void WPKitLogDebug(NSString *str, ...) NS_FORMAT_FUNCTION(1, 2); +FOUNDATION_EXTERN void WPKitLogVerbose(NSString *str, ...) NS_FORMAT_FUNCTION(1, 2); + +FOUNDATION_EXTERN void WPKitLogvError(NSString *str, va_list args) NS_FORMAT_FUNCTION(1, 0); +FOUNDATION_EXTERN void WPKitLogvWarning(NSString *str, va_list args) NS_FORMAT_FUNCTION(1, 0); +FOUNDATION_EXTERN void WPKitLogvInfo(NSString *str, va_list args) NS_FORMAT_FUNCTION(1, 0); +FOUNDATION_EXTERN void WPKitLogvDebug(NSString *str, va_list args) NS_FORMAT_FUNCTION(1, 0); +FOUNDATION_EXTERN void WPKitLogvVerbose(NSString *str, va_list args) NS_FORMAT_FUNCTION(1, 0); + +NS_ASSUME_NONNULL_END diff --git a/Modules/Sources/WordPressKitObjC/include/WPMapFilterReduce.h b/Modules/Sources/WordPressKitObjC/include/WPMapFilterReduce.h new file mode 100644 index 000000000000..c7968cf91365 --- /dev/null +++ b/Modules/Sources/WordPressKitObjC/include/WPMapFilterReduce.h @@ -0,0 +1,22 @@ +#import + +typedef id (^WPKitMapBlock)(id obj); +typedef BOOL (^WPKitFilterBlock)(id obj); + +@interface NSArray (WPKitMapFilterReduce) + +/** + Transforms values in an array + + The resulting array will include the results of calling mapBlock for each of + the receiver array objects. If mapBlock returns nil that value will be missing + from the resulting array. + */ +- (NSArray *)wpkit_map:(WPKitMapBlock)mapBlock; + +/** + Filters an array to only include values that satisfy the filter block + */ +- (NSArray *)wpkit_filter:(WPKitFilterBlock)filterBlock; + +@end diff --git a/Modules/Sources/WordPressKitObjC/include/WordPressComRESTAPIInterfacing.h b/Modules/Sources/WordPressKitObjC/include/WordPressComRESTAPIInterfacing.h new file mode 100644 index 000000000000..435a2d502c9b --- /dev/null +++ b/Modules/Sources/WordPressKitObjC/include/WordPressComRESTAPIInterfacing.h @@ -0,0 +1,35 @@ +@import Foundation; + +@class FilePart; + +@protocol WordPressComRESTAPIInterfacing + +@property (strong, nonatomic, readonly) NSURL * _Nonnull baseURL; + +/// - Note: `parameters` has `id` instead of the more common `NSObject *` as its value type so it will convert to `AnyObject` in Swift. +/// In Swift, it's simpler to work with `AnyObject` than with `NSObject`. For example `"abc" as AnyObject` over `"abc" as NSObject`. +- (NSProgress * _Nullable)get:(NSString * _Nonnull)URLString + parameters:(NSDictionary * _Nullable)parameters + success:(void (^ _Nonnull)(id _Nonnull, NSHTTPURLResponse * _Nullable))success + failure:(void (^ _Nonnull)(NSError * _Nonnull, NSHTTPURLResponse * _Nullable))failure; + +/// - Note: `parameters` has `id` instead of the more common `NSObject *` as its value type so it will convert to `AnyObject` in Swift. +/// In Swift, it's simpler to work with `AnyObject` than with `NSObject`. For example `"abc" as AnyObject` over `"abc" as NSObject`. +- (NSProgress * _Nullable)post:(NSString * _Nonnull)URLString + parameters:(NSDictionary * _Nullable)parameters + success:(void (^ _Nonnull)(id _Nonnull, NSHTTPURLResponse * _Nullable))success + failure:(void (^ _Nonnull)(NSError * _Nonnull, NSHTTPURLResponse * _Nullable))failure; + +- (NSProgress * _Nullable)multipartPOST:(NSString * _Nonnull)URLString + parameters:(NSDictionary * _Nullable)parameters + fileParts:(NSArray * _Nonnull)fileParts + requestEnqueued:(void (^ _Nullable)(NSNumber * _Nonnull))requestEnqueue + success:(void (^ _Nonnull)(id _Nonnull, NSHTTPURLResponse * _Nullable))success + failure:(void (^ _Nonnull)(NSError * _Nonnull, NSHTTPURLResponse * _Nullable))failure; + +- (NSError * _Nonnull)unknownResponseError; +- (NSInteger)uploadFailedErrorCode; +- (NSString * _Nonnull)errorCodeKey; +- (NSString * _Nonnull)errorMessageKey; + +@end diff --git a/Modules/Sources/WordPressKitObjC/include/WordPressComRESTAPIVersion.h b/Modules/Sources/WordPressKitObjC/include/WordPressComRESTAPIVersion.h new file mode 100644 index 000000000000..9625473ee97a --- /dev/null +++ b/Modules/Sources/WordPressKitObjC/include/WordPressComRESTAPIVersion.h @@ -0,0 +1,9 @@ +#import + +typedef NS_ENUM(NSInteger, WordPressComRESTAPIVersion) { + WordPressComRESTAPIVersion_1_0 = 1000, + WordPressComRESTAPIVersion_1_1 = 1001, + WordPressComRESTAPIVersion_1_2 = 1002, + WordPressComRESTAPIVersion_1_3 = 1003, + WordPressComRESTAPIVersion_2_0 = 2000 +}; diff --git a/Modules/Sources/WordPressKitObjC/include/WordPressComRESTAPIVersionedPathBuilder.h b/Modules/Sources/WordPressKitObjC/include/WordPressComRESTAPIVersionedPathBuilder.h new file mode 100644 index 000000000000..1c268db37a70 --- /dev/null +++ b/Modules/Sources/WordPressKitObjC/include/WordPressComRESTAPIVersionedPathBuilder.h @@ -0,0 +1,14 @@ +#import +#if SWIFT_PACKAGE +#import "WordPressComRESTAPIVersion.h" +#else +#import "WordPressComRESTAPIVersion.h" +#endif + +@interface WordPressComRESTAPIVersionedPathBuilder: NSObject + ++ (NSString *)pathForEndpoint:(NSString *)endpoint + withVersion:(WordPressComRESTAPIVersion)apiVersion +NS_SWIFT_NAME(path(forEndpoint:withVersion:)); + +@end diff --git a/Modules/Sources/WordPressKitObjC/include/WordPressComRestApiErrorDomain.h b/Modules/Sources/WordPressKitObjC/include/WordPressComRestApiErrorDomain.h new file mode 100644 index 000000000000..c4b6aa423b93 --- /dev/null +++ b/Modules/Sources/WordPressKitObjC/include/WordPressComRestApiErrorDomain.h @@ -0,0 +1,9 @@ +#import + +/// Error domain of `NSError` instances that are converted from `WordPressComRestApiEndpointError` +/// and `WordPressAPIError` instances. +/// +/// This matches the compiler generated value and is used to ensure consistent error domain across error types and SPM or Framework build modes. +/// +/// See `extension WordPressComRestApiEndpointError: CustomNSError` in CoreAPI package for context. +static NSString *const _Nonnull WordPressComRestApiErrorDomain = @"WordPressKit.WordPressComRestApiError"; diff --git a/Modules/Sources/WordPressKitObjC/include/WordPressComServiceRemote.h b/Modules/Sources/WordPressKitObjC/include/WordPressComServiceRemote.h new file mode 100644 index 000000000000..87aa22e59486 --- /dev/null +++ b/Modules/Sources/WordPressKitObjC/include/WordPressComServiceRemote.h @@ -0,0 +1,105 @@ +#import +#import "ServiceRemoteWordPressComREST.h" + +typedef NS_ENUM(NSUInteger, WordPressComServiceBlogVisibility) { + WordPressComServiceBlogVisibilityPublic = 0, + WordPressComServiceBlogVisibilityPrivate = 1, + WordPressComServiceBlogVisibilityHidden = 2, +}; + +typedef void(^WordPressComServiceSuccessBlock)(NSDictionary *responseDictionary); +typedef void(^WordPressComServiceFailureBlock)(NSError *error); + +/** + * @class WordPressComServiceRemote + * @brief Encapsulates exclusive WordPress.com services. + */ +@interface WordPressComServiceRemote : ServiceRemoteWordPressComREST + +/** + * @brief Creates a WordPress.com account with the specified parameters. + * + * @param email The email to use for the new account. Cannot be nil. + * @param username The username of the new account. Cannot be nil. + * @param password The password of the new account. Cannot be nil. + * @param success The block to execute on success. Can be nil. + * @param failure The block to execute on failure. Can be nil. + */ +- (void)createWPComAccountWithEmail:(NSString *)email + andUsername:(NSString *)username + andPassword:(NSString *)password + andClientID:(NSString *)clientID + andClientSecret:(NSString *)clientSecret + success:(WordPressComServiceSuccessBlock)success + failure:(WordPressComServiceFailureBlock)failure; + +/** + Create a new account using Google + + @param token token provided by Google + @param clientID wpcom client id + @param clientSecret wpcom secret + @param success success block + @param failure failure block + */ +- (void)createWPComAccountWithGoogle:(NSString *)token + andClientID:(NSString *)clientID + andClientSecret:(NSString *)clientSecret + success:(WordPressComServiceSuccessBlock)success + failure:(WordPressComServiceFailureBlock)failure; + +/** + * @brief Create a new WordPress.com account from Apple ID credentials. + * + * @param token Token provided by Apple. + * @param email Apple email to use for new account. + * @param fullName The user's full name for the new account. Formed from the fullname + * property in the Apple ID credential. + * @param clientID wpcom client ID. + * @param clientSecret wpcom secret. + * @param success success block. + * @param failure failure block. + */ +- (void)createWPComAccountWithApple:(NSString *)token + andEmail:(NSString *)email + andFullName:(NSString *)fullName + andClientID:(NSString *)clientID + andClientSecret:(NSString *)clientSecret + success:(WordPressComServiceSuccessBlock)success + failure:(WordPressComServiceFailureBlock)failure; + +/** + * @brief Validates a WordPress.com blog with the specified parameters. + * + * @param blogUrl The url of the blog to validate. Cannot be nil. + * @param blogTitle The title of the blog. Can be nil. + * @param success The block to execute on success. Can be nil. + * @param failure The block to execute on failure. Can be nil. + */ +- (void)validateWPComBlogWithUrl:(NSString *)blogUrl + andBlogTitle:(NSString *)blogTitle + andLanguageId:(NSString *)languageId + andClientID:(NSString *)clientID + andClientSecret:(NSString *)clientSecret + success:(WordPressComServiceSuccessBlock)success + failure:(WordPressComServiceFailureBlock)failure; + +/** + * @brief Creates a WordPress.com blog with the specified parameters. + * + * @param blogUrl The url of the blog to validate. Cannot be nil. + * @param blogTitle The title of the blog. Can be nil. + * @param visibility The visibility of the new blog. + * @param success The block to execute on success. Can be nil. + * @param failure The block to execute on failure. Can be nil. + */ +- (void)createWPComBlogWithUrl:(NSString *)blogUrl + andBlogTitle:(NSString *)blogTitle + andLanguageId:(NSString *)languageId + andBlogVisibility:(WordPressComServiceBlogVisibility)visibility + andClientID:(NSString *)clientID + andClientSecret:(NSString *)clientSecret + success:(WordPressComServiceSuccessBlock)success + failure:(WordPressComServiceFailureBlock)failure; + +@end diff --git a/Modules/Sources/WordPressKitObjC/include/WordPressOrgXMLRPCApiInterfacing.h b/Modules/Sources/WordPressKitObjC/include/WordPressOrgXMLRPCApiInterfacing.h new file mode 100644 index 000000000000..005383099ece --- /dev/null +++ b/Modules/Sources/WordPressKitObjC/include/WordPressOrgXMLRPCApiInterfacing.h @@ -0,0 +1,19 @@ +@import Foundation; + +NS_ASSUME_NONNULL_BEGIN + +@protocol WordPressOrgXMLRPCApiInterfacing + +- (NSProgress *)callMethod:(NSString *)method + parameters:(NSArray * _Nullable)parameters + success:(void (^)(id responseObject, NSHTTPURLResponse * _Nullable httpResponse))success + failure:(void (^)(NSError *error, NSHTTPURLResponse * _Nullable httpResponse))failure; + +- (NSProgress *)streamCallMethod:(NSString *)method + parameters:(NSArray * _Nullable)parameters + success:(void (^)(id responseObject, NSHTTPURLResponse * _Nullable httpResponse))success + failure:(void (^)(NSError *error, NSHTTPURLResponse * _Nullable httpResponse))failure; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Modules/Sources/WordPressKitObjCUtils/NSString+Helpers.m b/Modules/Sources/WordPressKitObjCUtils/NSString+Helpers.m new file mode 100644 index 000000000000..43930bd7e92e --- /dev/null +++ b/Modules/Sources/WordPressKitObjCUtils/NSString+Helpers.m @@ -0,0 +1,197 @@ +#import "NSString+Helpers.h" +#import +#import "NSString+XMLExtensions.h" + +static NSString *const Ellipsis = @"\u2026"; + +@implementation NSString (WPKitHelpers) + +#pragma mark Helpers + +/** + Parses an WordPress core emoji IMG tag and returns the corresponding emoji character. + */ ++ (NSString *)emojiFromCoreEmojiImageTag:(NSString *)tag +{ + if ([tag rangeOfString:@" 0) { + NSTextCheckingResult *match = [matches firstObject]; + if (match.numberOfRanges == 2) { + NSRange range = [match rangeAtIndex:1]; + return [tag substringWithRange:range]; + } + } + + matches = [filenameRegex matchesInString:tag options:0 range:sourceRange]; + if ([matches count] > 0) { + NSTextCheckingResult *match = [matches firstObject]; + if (match.numberOfRanges == 2) { + NSRange range = [match rangeAtIndex:1]; + NSString *filename = [tag substringWithRange:range]; + return [self emojiCharacterFromCoreEmojiFilename:filename]; + } + } + + return nil; +} + +/** + Processes the filename of an core emoji image from `s.w.org/images/core/emoji` + and returns the unicode character for the emoji. + Filenames can be formatted as a single hex value, or for emoji comprised of + Unicode pairs, as two hex values separated by a dash. + */ ++ (NSString *)emojiCharacterFromCoreEmojiFilename:(NSString *)filename +{ + NSArray *components = [filename componentsSeparatedByString:@"-"]; + NSMutableArray *marr = [NSMutableArray array]; + for (NSString *string in components) { + NSString *unicodeChar = [NSString unicodeCharacterFromHexString:string]; + if (unicodeChar) { + [marr addObject:unicodeChar]; + } + } + + return [marr componentsJoinedByString:@""]; +} + ++ (NSString *)unicodeCharacterFromHexString:(NSString *)hexString +{ + NSScanner *scanner = [NSScanner scannerWithString:hexString]; + unsigned long long hex = 0; + BOOL success = [scanner scanHexLongLong:&hex]; + if (!success) { + return nil; + } + return [[NSString alloc] initWithBytes:&hex length:4 encoding:NSUTF32LittleEndianStringEncoding]; +} + +// Taken from AFNetworking's AFPercentEscapedQueryStringPairMemberFromStringWithEncoding +- (NSString *)wpkit_stringByUrlEncoding +{ + NSMutableCharacterSet * allowedCharacterSet = [[NSCharacterSet URLQueryAllowedCharacterSet] mutableCopy]; + NSString *charactersToLeaveUnescaped = @"[]."; + [allowedCharacterSet addCharactersInString:charactersToLeaveUnescaped]; + return [self stringByAddingPercentEncodingWithAllowedCharacters:allowedCharacterSet]; +} + +/* + * Uses a RegEx to strip all HTML tags from a string and unencode entites + */ +- (NSString *)wpkit_stringByStrippingHTML +{ + return [self stringByReplacingOccurrencesOfString:@"<[^>]+>" withString:@"" options:NSRegularExpressionSearch range:NSMakeRange(0, self.length)]; +} + +// A method to truncate a string at a predetermined length and append ellipsis to the end + +- (NSString *)wpkit_stringByEllipsizingWithMaxLength:(NSInteger)lengthlimit preserveWords:(BOOL)preserveWords +{ + NSInteger currentLength = [self length]; + NSString *result = @""; + NSString *temp = @""; + + if (currentLength <= lengthlimit) { //If the string is already within limits + return self; + } else if (lengthlimit > 0) { //If the string is longer than the limit, and the limit is larger than 0. + + NSInteger newLimitWithoutEllipsis = lengthlimit - [Ellipsis length]; + + if (preserveWords) { + + NSArray *wordsSeperated = [self tokenize]; + + if ([wordsSeperated count] == 1) { // If this is a long word then we disregard preserveWords property. + return [NSString stringWithFormat:@"%@%@", [self substringToIndex:newLimitWithoutEllipsis], Ellipsis]; + } + + for (NSString *word in wordsSeperated) { + + if ([temp isEqualToString:@""]) { + temp = word; + } else { + temp = [NSString stringWithFormat:@"%@%@", temp, word]; + } + + if ([temp length] <= newLimitWithoutEllipsis) { + result = [temp copy]; + } else { + return [NSString stringWithFormat:@"%@%@",result,Ellipsis]; + } + } + } else { + return [NSString stringWithFormat:@"%@%@", [self substringToIndex:newLimitWithoutEllipsis], Ellipsis]; + } + + } else { //if the limit is 0. + return @""; + } + + return self; +} + +- (NSArray *)tokenize +{ + CFLocaleRef locale = CFLocaleCopyCurrent(); + CFRange stringRange = CFRangeMake(0, [self length]); + + CFStringTokenizerRef tokenizer = CFStringTokenizerCreate(kCFAllocatorDefault, + (CFStringRef)self, + stringRange, + kCFStringTokenizerUnitWordBoundary, + locale); + + CFStringTokenizerTokenType tokenType = CFStringTokenizerAdvanceToNextToken(tokenizer); + + NSMutableArray *tokens = [NSMutableArray new]; + + while (tokenType != kCFStringTokenizerTokenNone) { + stringRange = CFStringTokenizerGetCurrentTokenRange(tokenizer); + NSString *token = [self substringWithRange:NSMakeRange(stringRange.location, stringRange.length)]; + [tokens addObject:token]; + tokenType = CFStringTokenizerAdvanceToNextToken(tokenizer); + } + + CFRelease(locale); + CFRelease(tokenizer); + + return tokens; +} + +- (bool)wpkit_isEmpty { + return self.length == 0; +} + +@end + +@implementation NSString (WPKitNumericValueHack) + +- (NSNumber *)wpkit_numericValue { + return [NSNumber numberWithUnsignedLongLong:[self longLongValue]]; +} + +@end + +@implementation NSObject (WPKitNumericValueHack) +- (NSNumber *)wpkit_numericValue { + if ([self isKindOfClass:[NSNumber class]]) { + return (NSNumber *)self; + } + return nil; +} +@end diff --git a/Modules/Sources/WordPressKitObjCUtils/NSString+MD5.m b/Modules/Sources/WordPressKitObjCUtils/NSString+MD5.m new file mode 100644 index 000000000000..23d5519d5c24 --- /dev/null +++ b/Modules/Sources/WordPressKitObjCUtils/NSString+MD5.m @@ -0,0 +1,24 @@ +#import "NSString+MD5.h" +#import + + +@implementation NSString (MD5) + +- (NSString *)md5 +{ + const char *cStr = [self UTF8String]; + unsigned char result[CC_MD5_DIGEST_LENGTH]; + +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wdeprecated-declarations" // because Apple considers MD5 insecure + CC_MD5(cStr, (CC_LONG)strlen(cStr), result); +#pragma clang diagnostic pop + + return [NSString stringWithFormat: + @"%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x", + result[0], result[1], result[2], result[3], result[4], result[5], result[6], result[7], + result[8], result[9], result[10], result[11], result[12], result[13], result[14], result[15] + ]; +} + +@end diff --git a/Modules/Sources/WordPressKitObjCUtils/NSString+XMLExtensions.m b/Modules/Sources/WordPressKitObjCUtils/NSString+XMLExtensions.m new file mode 100644 index 000000000000..19ee5fb74f21 --- /dev/null +++ b/Modules/Sources/WordPressKitObjCUtils/NSString+XMLExtensions.m @@ -0,0 +1,400 @@ +// Adapted from MWFeedParser +// https://github.com/mwaterfall/MWFeedParser Copyright (c) 2010 Michael Waterfall + +#import "NSString+XMLExtensions.h" + + +typedef struct { + __unsafe_unretained NSString *escapeSequence; + unichar uchar; +} HTMLEscapeMap; + + +// Taken from http://www.w3.org/TR/xhtml1/dtds.html#a_dtd_Special_characters +// Ordered by uchar lowest to highest for bsearching +static HTMLEscapeMap gAsciiHTMLEscapeMap[] = { + // A.2.2. Special characters + { @""", 34 }, + { @"&", 38 }, + { @"'", 39 }, + { @"<", 60 }, + { @">", 62 }, + + // A.2.1. Latin-1 characters + { @" ", 160 }, + { @"¡", 161 }, + { @"¢", 162 }, + { @"£", 163 }, + { @"¤", 164 }, + { @"¥", 165 }, + { @"¦", 166 }, + { @"§", 167 }, + { @"¨", 168 }, + { @"©", 169 }, + { @"ª", 170 }, + { @"«", 171 }, + { @"¬", 172 }, + { @"­", 173 }, + { @"®", 174 }, + { @"¯", 175 }, + { @"°", 176 }, + { @"±", 177 }, + { @"²", 178 }, + { @"³", 179 }, + { @"´", 180 }, + { @"µ", 181 }, + { @"¶", 182 }, + { @"·", 183 }, + { @"¸", 184 }, + { @"¹", 185 }, + { @"º", 186 }, + { @"»", 187 }, + { @"¼", 188 }, + { @"½", 189 }, + { @"¾", 190 }, + { @"¿", 191 }, + { @"À", 192 }, + { @"Á", 193 }, + { @"Â", 194 }, + { @"Ã", 195 }, + { @"Ä", 196 }, + { @"Å", 197 }, + { @"Æ", 198 }, + { @"Ç", 199 }, + { @"È", 200 }, + { @"É", 201 }, + { @"Ê", 202 }, + { @"Ë", 203 }, + { @"Ì", 204 }, + { @"Í", 205 }, + { @"Î", 206 }, + { @"Ï", 207 }, + { @"Ð", 208 }, + { @"Ñ", 209 }, + { @"Ò", 210 }, + { @"Ó", 211 }, + { @"Ô", 212 }, + { @"Õ", 213 }, + { @"Ö", 214 }, + { @"×", 215 }, + { @"Ø", 216 }, + { @"Ù", 217 }, + { @"Ú", 218 }, + { @"Û", 219 }, + { @"Ü", 220 }, + { @"Ý", 221 }, + { @"Þ", 222 }, + { @"ß", 223 }, + { @"à", 224 }, + { @"á", 225 }, + { @"â", 226 }, + { @"ã", 227 }, + { @"ä", 228 }, + { @"å", 229 }, + { @"æ", 230 }, + { @"ç", 231 }, + { @"è", 232 }, + { @"é", 233 }, + { @"ê", 234 }, + { @"ë", 235 }, + { @"ì", 236 }, + { @"í", 237 }, + { @"î", 238 }, + { @"ï", 239 }, + { @"ð", 240 }, + { @"ñ", 241 }, + { @"ò", 242 }, + { @"ó", 243 }, + { @"ô", 244 }, + { @"õ", 245 }, + { @"ö", 246 }, + { @"÷", 247 }, + { @"ø", 248 }, + { @"ù", 249 }, + { @"ú", 250 }, + { @"û", 251 }, + { @"ü", 252 }, + { @"ý", 253 }, + { @"þ", 254 }, + { @"ÿ", 255 }, + + // A.2.2. Special characters cont'd + { @"Œ", 338 }, + { @"œ", 339 }, + { @"Š", 352 }, + { @"š", 353 }, + { @"Ÿ", 376 }, + + // A.2.3. Symbols + { @"ƒ", 402 }, + + // A.2.2. Special characters cont'd + { @"ˆ", 710 }, + { @"˜", 732 }, + + // A.2.3. Symbols cont'd + { @"Α", 913 }, + { @"Β", 914 }, + { @"Γ", 915 }, + { @"Δ", 916 }, + { @"Ε", 917 }, + { @"Ζ", 918 }, + { @"Η", 919 }, + { @"Θ", 920 }, + { @"Ι", 921 }, + { @"Κ", 922 }, + { @"Λ", 923 }, + { @"Μ", 924 }, + { @"Ν", 925 }, + { @"Ξ", 926 }, + { @"Ο", 927 }, + { @"Π", 928 }, + { @"Ρ", 929 }, + { @"Σ", 931 }, + { @"Τ", 932 }, + { @"Υ", 933 }, + { @"Φ", 934 }, + { @"Χ", 935 }, + { @"Ψ", 936 }, + { @"Ω", 937 }, + { @"α", 945 }, + { @"β", 946 }, + { @"γ", 947 }, + { @"δ", 948 }, + { @"ε", 949 }, + { @"ζ", 950 }, + { @"η", 951 }, + { @"θ", 952 }, + { @"ι", 953 }, + { @"κ", 954 }, + { @"λ", 955 }, + { @"μ", 956 }, + { @"ν", 957 }, + { @"ξ", 958 }, + { @"ο", 959 }, + { @"π", 960 }, + { @"ρ", 961 }, + { @"ς", 962 }, + { @"σ", 963 }, + { @"τ", 964 }, + { @"υ", 965 }, + { @"φ", 966 }, + { @"χ", 967 }, + { @"ψ", 968 }, + { @"ω", 969 }, + { @"ϑ", 977 }, + { @"ϒ", 978 }, + { @"ϖ", 982 }, + + // A.2.2. Special characters cont'd + { @" ", 8194 }, + { @" ", 8195 }, + { @" ", 8201 }, + { @"‌", 8204 }, + { @"‍", 8205 }, + { @"‎", 8206 }, + { @"‏", 8207 }, + { @"–", 8211 }, + { @"—", 8212 }, + { @"‘", 8216 }, + { @"’", 8217 }, + { @"‚", 8218 }, + { @"“", 8220 }, + { @"”", 8221 }, + { @"„", 8222 }, + { @"†", 8224 }, + { @"‡", 8225 }, + // A.2.3. Symbols cont'd + { @"•", 8226 }, + { @"…", 8230 }, + + // A.2.2. Special characters cont'd + { @"‰", 8240 }, + + // A.2.3. Symbols cont'd + { @"′", 8242 }, + { @"″", 8243 }, + + // A.2.2. Special characters cont'd + { @"‹", 8249 }, + { @"›", 8250 }, + + // A.2.3. Symbols cont'd + { @"‾", 8254 }, + { @"⁄", 8260 }, + + // A.2.2. Special characters cont'd + { @"€", 8364 }, + + // A.2.3. Symbols cont'd + { @"ℑ", 8465 }, + { @"℘", 8472 }, + { @"ℜ", 8476 }, + { @"™", 8482 }, + { @"ℵ", 8501 }, + { @"←", 8592 }, + { @"↑", 8593 }, + { @"→", 8594 }, + { @"↓", 8595 }, + { @"↔", 8596 }, + { @"↵", 8629 }, + { @"⇐", 8656 }, + { @"⇑", 8657 }, + { @"⇒", 8658 }, + { @"⇓", 8659 }, + { @"⇔", 8660 }, + { @"∀", 8704 }, + { @"∂", 8706 }, + { @"∃", 8707 }, + { @"∅", 8709 }, + { @"∇", 8711 }, + { @"∈", 8712 }, + { @"∉", 8713 }, + { @"∋", 8715 }, + { @"∏", 8719 }, + { @"∑", 8721 }, + { @"−", 8722 }, + { @"∗", 8727 }, + { @"√", 8730 }, + { @"∝", 8733 }, + { @"∞", 8734 }, + { @"∠", 8736 }, + { @"∧", 8743 }, + { @"∨", 8744 }, + { @"∩", 8745 }, + { @"∪", 8746 }, + { @"∫", 8747 }, + { @"∴", 8756 }, + { @"∼", 8764 }, + { @"≅", 8773 }, + { @"≈", 8776 }, + { @"≠", 8800 }, + { @"≡", 8801 }, + { @"≤", 8804 }, + { @"≥", 8805 }, + { @"⊂", 8834 }, + { @"⊃", 8835 }, + { @"⊄", 8836 }, + { @"⊆", 8838 }, + { @"⊇", 8839 }, + { @"⊕", 8853 }, + { @"⊗", 8855 }, + { @"⊥", 8869 }, + { @"⋅", 8901 }, + { @"⌈", 8968 }, + { @"⌉", 8969 }, + { @"⌊", 8970 }, + { @"⌋", 8971 }, + { @"⟨", 9001 }, + { @"⟩", 9002 }, + { @"◊", 9674 }, + { @"♠", 9824 }, + { @"♣", 9827 }, + { @"♥", 9829 }, + { @"♦", 9830 } +}; + + +@implementation NSString (WPKitXMLExtensions) + ++ (NSString *)wpkit_encodeXMLCharactersIn : (NSString *)source { + if (![source isKindOfClass:[NSString class]] || !source) + return @""; + + NSString *result = [NSString stringWithString:source]; + + // NOTE: we use unicode entities instead of & > < since some weird hosts (powweb, fatcow, and cousins) + // have a weird PHP/libxml2 combination that ignores regular entities + if ([result rangeOfString:@"&"].location != NSNotFound) + result = [[result componentsSeparatedByString:@"&"] componentsJoinedByString:@"&"]; + + if ([result rangeOfString:@"<"].location != NSNotFound) + result = [[result componentsSeparatedByString:@"<"] componentsJoinedByString:@"<"]; + + if ([result rangeOfString:@">"].location != NSNotFound) + result = [[result componentsSeparatedByString:@">"] componentsJoinedByString:@">"]; + + return result; +} + + ++ (NSString *) wpkit_decodeXMLCharactersIn:(NSString *)original { + if (![original isKindOfClass:[NSString class]] || !original) + return @""; + + NSString *source = [NSString stringWithString:original]; + + NSRange range = NSMakeRange(0, [source length]); + NSRange subrange = [source rangeOfString:@"&" options:NSBackwardsSearch range:range]; + + // if no ampersands, we've got a quick way out + if (subrange.length == 0) return source; + NSMutableString *finalString = [NSMutableString stringWithString:source]; + do { + NSRange semiColonRange = NSMakeRange(subrange.location, NSMaxRange(range) - subrange.location); + semiColonRange = [source rangeOfString:@";" options:0 range:semiColonRange]; + range = NSMakeRange(0, subrange.location); + // if we don't find a semicolon in the range, we don't have a sequence + if (semiColonRange.location == NSNotFound) { + continue; + } + NSRange escapeRange = NSMakeRange(subrange.location, semiColonRange.location - subrange.location + 1); + NSString *escapeString = [source substringWithRange:escapeRange]; + NSUInteger length = [escapeString length]; + // a squence must be longer than 3 (<) and less than 11 (ϑ) + if (length > 3 && length < 11) { + if ([escapeString characterAtIndex:1] == '#') { + unichar char2 = [escapeString characterAtIndex:2]; + if (char2 == 'x' || char2 == 'X') { + // Hex escape squences £ + NSString *hexSequence = [escapeString substringWithRange:NSMakeRange(3, length - 4)]; + NSScanner *scanner = [NSScanner scannerWithString:hexSequence]; + unsigned value; + if ([scanner scanHexInt:&value] && + value < USHRT_MAX && + value > 0 + && [scanner scanLocation] == length - 4) { + unichar uchar = value; + NSString *charString = [NSString stringWithCharacters:&uchar length:1]; + [finalString replaceCharactersInRange:escapeRange withString:charString]; + } + + } else { + // Decimal Sequences { + NSString *numberSequence = [escapeString substringWithRange:NSMakeRange(2, length - 3)]; + NSScanner *scanner = [NSScanner scannerWithString:numberSequence]; + int value; + if ([scanner scanInt:&value] && + value < USHRT_MAX && + value > 0 + && [scanner scanLocation] == length - 3) { + unichar uchar = value; + NSString *charString = [NSString stringWithCharacters:&uchar length:1]; + [finalString replaceCharactersInRange:escapeRange withString:charString]; + } + } + } else { + // "standard" sequences + for (unsigned i = 0; i < sizeof(gAsciiHTMLEscapeMap) / sizeof(HTMLEscapeMap); ++i) { + if ([escapeString isEqualToString:gAsciiHTMLEscapeMap[i].escapeSequence]) { + [finalString replaceCharactersInRange:escapeRange withString:[NSString stringWithCharacters:&gAsciiHTMLEscapeMap[i].uchar length:1]]; + break; + } + } + } + } + + } while ((subrange = [source rangeOfString:@"&" options:NSBackwardsSearch range:range]).length != 0); + + return finalString; +} + +- (NSString *)wpkit_stringByDecodingXMLCharacters { + return [NSString wpkit_decodeXMLCharactersIn:self]; +} +- (NSString *)wpkit_stringByEncodingXMLCharacters { + return [NSString wpkit_encodeXMLCharactersIn:self]; +} + + +@end diff --git a/Modules/Sources/WordPressKitObjCUtils/include/NSString+Helpers.h b/Modules/Sources/WordPressKitObjCUtils/include/NSString+Helpers.h new file mode 100644 index 000000000000..a6f28ced6b56 --- /dev/null +++ b/Modules/Sources/WordPressKitObjCUtils/include/NSString+Helpers.h @@ -0,0 +1,18 @@ +#import + +@interface NSString (WPKitHelpers) + +- (NSString *)wpkit_stringByUrlEncoding; +- (NSString *)wpkit_stringByStrippingHTML; +- (NSString *)wpkit_stringByEllipsizingWithMaxLength:(NSInteger)lengthlimit preserveWords:(BOOL)preserveWords; +- (bool)wpkit_isEmpty; + +@end + +@interface NSString (WPKitNumericValueHack) +- (NSNumber *)wpkit_numericValue; +@end + +@interface NSObject (WPKitNumericValueHack) +- (NSNumber *)wpkit_numericValue; +@end diff --git a/Modules/Sources/WordPressKitObjCUtils/include/NSString+MD5.h b/Modules/Sources/WordPressKitObjCUtils/include/NSString+MD5.h new file mode 100644 index 000000000000..e1c599fbf288 --- /dev/null +++ b/Modules/Sources/WordPressKitObjCUtils/include/NSString+MD5.h @@ -0,0 +1,7 @@ +#import + +@interface NSString (MD5) + +- (NSString *)md5; + +@end diff --git a/Modules/Sources/WordPressKitObjCUtils/include/NSString+XMLExtensions.h b/Modules/Sources/WordPressKitObjCUtils/include/NSString+XMLExtensions.h new file mode 100644 index 000000000000..f2de4533e67e --- /dev/null +++ b/Modules/Sources/WordPressKitObjCUtils/include/NSString+XMLExtensions.h @@ -0,0 +1,10 @@ +#import + +@interface NSString (WPKitXMLExtensions) + ++ (NSString *)wpkit_encodeXMLCharactersIn : (NSString *)source; ++ (NSString *)wpkit_decodeXMLCharactersIn : (NSString *)source; +- (NSString *)wpkit_stringByDecodingXMLCharacters; +- (NSString *)wpkit_stringByEncodingXMLCharacters; + +@end