diff --git a/Package.swift b/Package.swift index 3172714e3..dcc9c7af3 100644 --- a/Package.swift +++ b/Package.swift @@ -11,8 +11,8 @@ let package = Package( targets: [ .binaryTarget( name: "WordPressKit", - url: "https://github.com/user-attachments/files/19949939/WordPressKit.zip", - checksum: "ba06ff0716595023dd6c98b6a5bc74d4abb35bfb668e24026ffd041460f59137" + url: "https://github.com/user-attachments/files/20175119/WordPressKit.zip", + checksum: "13aa0e5952616a2f01a0f0db370ee7925d58253c2aab6e216671e8a013ab471b" ), ] ) diff --git a/Sources/WordPressKit/Models/RemoteSubscriber.swift b/Sources/WordPressKit/Models/RemoteSubscriber.swift deleted file mode 100644 index f6414b7d4..000000000 --- a/Sources/WordPressKit/Models/RemoteSubscriber.swift +++ /dev/null @@ -1,23 +0,0 @@ -import Foundation - -public struct RemoteSubscriber: Decodable { - public let userID: Int - public let subscriptionID: Int - public let emailAddress: String? - public let dateSubscribed: Date - public let isEmailSubscriber: Bool - public let subscriptionStatus: String? - public let displayName: String? - public let avatar: String? - - private enum CodingKeys: String, CodingKey { - case userID = "user_id" - case subscriptionID = "subscription_id" - case emailAddress = "email_address" - case dateSubscribed = "date_subscribed" - case isEmailSubscriber = "is_email_subscriber" - case subscriptionStatus = "subscription_status" - case displayName = "display_name" - case avatar - } -} diff --git a/Sources/WordPressKit/Services/PeopleServiceRemote.swift b/Sources/WordPressKit/Services/PeopleServiceRemote.swift index 5c40228bb..e23b26ae3 100644 --- a/Sources/WordPressKit/Services/PeopleServiceRemote.swift +++ b/Sources/WordPressKit/Services/PeopleServiceRemote.swift @@ -173,91 +173,6 @@ public class PeopleServiceRemote: ServiceRemoteWordPressComREST { }) } - public struct SubscribersParameters { - public var sortField: SortField? - public var sortOrder: SortOrder? - public var filters: [Filter] - - public enum SortField: String { - case dateSubscribed = "date_subscribed" - case email = "email" - case name = "name" - case plan = "plan" - case subscriptionStatus = "subscription_status" - } - - public enum SortOrder: String { - case ascending = "asc" - case descending = "dsc" - } - - public protocol Filter: CustomStringConvertible {} - - public enum FilterSubscriptionType: String, Filter { - case email = "email_subscriber" - case reader = "reader_subscriber" - case unconfirmed = "unconfirmed_subscriber" - case blocked = "blocked_subscriber" - - public var description: String { rawValue } - } - - public enum FilterPaymentType: String, Filter { - case free - case paid - - public var description: String { rawValue } - } - - public init(sortField: SortField? = nil, sortOrder: SortOrder? = nil, filters: [Filter] = []) { - self.sortField = sortField - self.sortOrder = sortOrder - self.filters = filters - } - } - - public struct SubscribersResponse: Decodable { - public var total: Int - public var pages: Int - public var page: Int - public var subscribers: [RemoteSubscriber] - } - - public func getSubscribers( - siteID: Int, - page: Int? = nil, - perPage: Int? = 25, - parameters: SubscribersParameters = .init() - ) async throws -> SubscribersResponse { - 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.map { $0.description } - } - - let decoder = JSONDecoder() - decoder.dateDecodingStrategy = JSONDecoder.DateDecodingStrategy.supportMultipleDateFormats - - return try await wordPressComRestApi.perform( - .get, - URLString: url, - jsonDecoder: decoder, - type: SubscribersResponse.self - ).get().body - } - /// Updates a specified User's Role /// /// - Parameters: diff --git a/Sources/WordPressKit/Services/SubscribersServiceRemote.swift b/Sources/WordPressKit/Services/SubscribersServiceRemote.swift new file mode 100644 index 000000000..b8c6770a0 --- /dev/null +++ b/Sources/WordPressKit/Services/SubscribersServiceRemote.swift @@ -0,0 +1,286 @@ +import Foundation + +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/Sources/WordPressKit/Utility/StringCodingKey.swift b/Sources/WordPressKit/Utility/StringCodingKey.swift new file mode 100644 index 000000000..9f4c2bb11 --- /dev/null +++ b/Sources/WordPressKit/Utility/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/Tests/WordPressKitTests/Mock Data/site-subscriber-get-details-response-invalid-country.json b/Tests/WordPressKitTests/Mock Data/site-subscriber-get-details-response-invalid-country.json new file mode 100644 index 000000000..3b92ec640 --- /dev/null +++ b/Tests/WordPressKitTests/Mock Data/site-subscriber-get-details-response-invalid-country.json @@ -0,0 +1,15 @@ +{ + "user_id": 123, + "subscription_id": 123, + "email_address": "test@example.com", + "date_subscribed": "2025-04-17T14:40:00+00:00", + "is_email_subscriber": false, + "subscription_status": "Subscribed", + "avatar": "https://example.com/avatar", + "display_name": "Alex", + "url": "http://example.wordpress.com", + "country": { + "code": "", + "name": false + } +} diff --git a/Tests/WordPressKitTests/Mock Data/site-subscriber-get-details-response.json b/Tests/WordPressKitTests/Mock Data/site-subscriber-get-details-response.json new file mode 100644 index 000000000..b7d637e79 --- /dev/null +++ b/Tests/WordPressKitTests/Mock Data/site-subscriber-get-details-response.json @@ -0,0 +1,43 @@ +{ + "user_id": 123, + "subscription_id": 123, + "email_address": "test@example.com", + "date_subscribed": "2025-04-17T14:40:00+00:00", + "is_email_subscriber": false, + "subscription_status": "Subscribed", + "avatar": "https://example.com/avatar", + "display_name": "Alex", + "url": "http://example.wordpress.com", + "country": { + "code": "US", + "name": "United States" + }, + "plans": [ + { + "is_gift": false, + "gift_id": null, + "paid_subscription_id": "12422686", + "status": "active", + "title": "Newsletter Tier", + "currency": "USD", + "renew_interval": "1 month", + "inactive_renew_interval": null, + "renewal_price": 0.5, + "start_date": "2025-01-13T18:51:55+00:00", + "end_date": "2025-02-13T18:51:55+00:00" + }, + { + "is_gift": true, + "gift_id": 31, + "paid_subscription_id": null, + "status": "active", + "title": "Newsletter Tier 3", + "currency": "USD", + "renew_interval": "one-time", + "inactive_renew_interval": null, + "renewal_price": 0, + "start_date": "2025-05-08T14:50:28+00:00", + "end_date": "2025-06-07T14:50:28+00:00" + } + ] +} diff --git a/Tests/WordPressKitTests/Mock Data/site-subscriber-stats-response.json b/Tests/WordPressKitTests/Mock Data/site-subscriber-stats-response.json new file mode 100644 index 000000000..fbb848243 --- /dev/null +++ b/Tests/WordPressKitTests/Mock Data/site-subscriber-stats-response.json @@ -0,0 +1,6 @@ +{ + "emails_sent": 1, + "unique_opens": 2, + "unique_clicks": 3, + "blog_registration_date": "2024-12-04 16:00:32" +} diff --git a/Tests/WordPressKitTests/Tests/PeopleServiceRemoteTests.swift b/Tests/WordPressKitTests/Tests/PeopleServiceRemoteTests.swift index 1df76fb69..755a20681 100644 --- a/Tests/WordPressKitTests/Tests/PeopleServiceRemoteTests.swift +++ b/Tests/WordPressKitTests/Tests/PeopleServiceRemoteTests.swift @@ -794,18 +794,4 @@ class PeopleServiceRemoteTests: RemoteTestCase, RESTTestable { waitForExpectations(timeout: timeout, handler: nil) } - - func testDecodeSubscribersResponse() throws { - let data = try JSONLoader.data(named: "site-subscribers-response") - - let decoder = JSONDecoder() - decoder.dateDecodingStrategy = JSONDecoder.DateDecodingStrategy.supportMultipleDateFormats - - let response = try decoder.decode(PeopleServiceRemote.SubscribersResponse.self, from: data) - - XCTAssertEqual(response.total, 1) - - let subscriber = try XCTUnwrap(response.subscribers.first) - XCTAssertEqual(subscriber.userID, 1) - } } diff --git a/Tests/WordPressKitTests/Tests/SubscribersServiceRemoteTests.swift b/Tests/WordPressKitTests/Tests/SubscribersServiceRemoteTests.swift new file mode 100644 index 000000000..5e6f4bbd2 --- /dev/null +++ b/Tests/WordPressKitTests/Tests/SubscribersServiceRemoteTests.swift @@ -0,0 +1,58 @@ +import Foundation +import XCTest +@testable import WordPressKit + +class SubscribersServiceRemoteTests: RemoteTestCase, RESTTestable { + func testDecodeSubscribersResponse() throws { + let data = try JSONLoader.data(named: "site-subscribers-response") + + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = JSONDecoder.DateDecodingStrategy.supportMultipleDateFormats + + let response = try decoder.decode(SubscribersServiceRemote.GetSubscribersResponse.self, from: data) + + XCTAssertEqual(response.total, 1) + + let subscriber = try XCTUnwrap(response.subscribers.first) + XCTAssertEqual(subscriber.dotComUserID, 1) + } + + func testDecoderSubscriberDetailsResponse() throws { + let data = try JSONLoader.data(named: "site-subscriber-get-details-response") + + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = JSONDecoder.DateDecodingStrategy.supportMultipleDateFormats + + let response = try decoder.decode(SubscribersServiceRemote.GetSubscriberDetailsResponse.self, from: data) + + XCTAssertEqual(response.country?.code, "US") + XCTAssertEqual(response.country?.name, "United States") + + let plan = try XCTUnwrap(response.plans?.first) + XCTAssertFalse(plan.isGift) + XCTAssertEqual(plan.status, "active") + XCTAssertEqual(plan.paidSubscriptionId, "12422686") + } + + func testDecoderSubscriberDetailsInvalidCountry() throws { + let data = try JSONLoader.data(named: "site-subscriber-get-details-response-invalid-country") + + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = JSONDecoder.DateDecodingStrategy.supportMultipleDateFormats + + let response = try decoder.decode(SubscribersServiceRemote.GetSubscriberDetailsResponse.self, from: data) + + XCTAssertNil(response.country) + } + + func testDecoderSubscriberStatsResponse() throws { + let data = try JSONLoader.data(named: "site-subscriber-stats-response") + + let decoder = JSONDecoder.apiDecoder + let response = try decoder.decode(SubscribersServiceRemote.GetSubscriberStatsResponse.self, from: data) + + XCTAssertEqual(response.emailsSent, 1) + XCTAssertEqual(response.uniqueOpens, 2) + XCTAssertEqual(response.uniqueClicks, 3) + } +} diff --git a/WordPressKit.xcodeproj/project.pbxproj b/WordPressKit.xcodeproj/project.pbxproj index f5ef84179..c6177f16b 100644 --- a/WordPressKit.xcodeproj/project.pbxproj +++ b/WordPressKit.xcodeproj/project.pbxproj @@ -51,7 +51,12 @@ 0CCD4C5C2C41700B00B53F9A /* UIDevice+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CCD4C5B2C41700B00B53F9A /* UIDevice+Extensions.swift */; }; 0CCD4C5F2C41711800B53F9A /* NSObject-SafeExpectations in Frameworks */ = {isa = PBXBuildFile; productRef = 0CCD4C5E2C41711800B53F9A /* NSObject-SafeExpectations */; }; 0CCD4C622C41712800B53F9A /* wpxmlrpc in Frameworks */ = {isa = PBXBuildFile; productRef = 0CCD4C612C41712800B53F9A /* wpxmlrpc */; }; - 0CE4E8252DC027AC00056DD9 /* RemoteSubscriber.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CE4E8242DC027AC00056DD9 /* RemoteSubscriber.swift */; }; + 0CD5D3DD2DCE4F5500B4E679 /* StringCodingKey.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CD5D3DC2DCE4F5500B4E679 /* StringCodingKey.swift */; }; + 0CD5D3DF2DCE50D900B4E679 /* site-subscriber-get-details-response-invalid-country.json in Resources */ = {isa = PBXBuildFile; fileRef = 0CD5D3DE2DCE50D900B4E679 /* site-subscriber-get-details-response-invalid-country.json */; }; + 0CE311BD2DCBB52C003AADB3 /* SubscribersServiceRemote.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CE311BC2DCBB52C003AADB3 /* SubscribersServiceRemote.swift */; }; + 0CE311BF2DCBB588003AADB3 /* SubscribersServiceRemoteTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CE311BE2DCBB588003AADB3 /* SubscribersServiceRemoteTests.swift */; }; + 0CE311C52DCBB970003AADB3 /* site-subscriber-stats-response.json in Resources */ = {isa = PBXBuildFile; fileRef = 0CE311C42DCBB970003AADB3 /* site-subscriber-stats-response.json */; }; + 0CE311C72DCBBA01003AADB3 /* site-subscriber-get-details-response.json in Resources */ = {isa = PBXBuildFile; fileRef = 0CE311C62DCBBA01003AADB3 /* site-subscriber-get-details-response.json */; }; 0CED1FE82B617CF300E6DD52 /* AtomicSiteServiceRemote.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CED1FE72B617CF300E6DD52 /* AtomicSiteServiceRemote.swift */; }; 0CED1FEB2B617D7D00E6DD52 /* AtomicLogs.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CED1FEA2B617D7D00E6DD52 /* AtomicLogs.swift */; }; 1769DEAA24729AFF00F42EFC /* HomepageSettingsServiceRemote.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1769DEA924729AFF00F42EFC /* HomepageSettingsServiceRemote.swift */; }; @@ -827,7 +832,12 @@ 0CB1905F2A2A6943004D3E80 /* blaze-campaigns-search.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "blaze-campaigns-search.json"; sourceTree = ""; }; 0CB190642A2A7569004D3E80 /* BlazeCampaignsSearchResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlazeCampaignsSearchResponse.swift; sourceTree = ""; }; 0CCD4C5B2C41700B00B53F9A /* UIDevice+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIDevice+Extensions.swift"; sourceTree = ""; }; - 0CE4E8242DC027AC00056DD9 /* RemoteSubscriber.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteSubscriber.swift; sourceTree = ""; }; + 0CD5D3DC2DCE4F5500B4E679 /* StringCodingKey.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StringCodingKey.swift; sourceTree = ""; }; + 0CD5D3DE2DCE50D900B4E679 /* site-subscriber-get-details-response-invalid-country.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "site-subscriber-get-details-response-invalid-country.json"; sourceTree = ""; }; + 0CE311BC2DCBB52C003AADB3 /* SubscribersServiceRemote.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubscribersServiceRemote.swift; sourceTree = ""; }; + 0CE311BE2DCBB588003AADB3 /* SubscribersServiceRemoteTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubscribersServiceRemoteTests.swift; sourceTree = ""; }; + 0CE311C42DCBB970003AADB3 /* site-subscriber-stats-response.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "site-subscriber-stats-response.json"; sourceTree = ""; }; + 0CE311C62DCBBA01003AADB3 /* site-subscriber-get-details-response.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "site-subscriber-get-details-response.json"; sourceTree = ""; }; 0CED1FE72B617CF300E6DD52 /* AtomicSiteServiceRemote.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AtomicSiteServiceRemote.swift; sourceTree = ""; }; 0CED1FEA2B617D7D00E6DD52 /* AtomicLogs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AtomicLogs.swift; sourceTree = ""; }; 1769DEA924729AFF00F42EFC /* HomepageSettingsServiceRemote.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomepageSettingsServiceRemote.swift; sourceTree = ""; }; @@ -1870,7 +1880,6 @@ 4A68E3DC294070A7004AC3DC /* RemoteReaderSite.swift */, 4A68E3E0294076C1004AC3DC /* RemoteReaderSiteInfo.swift */, 9F3E0B9A208732B2009CB5BA /* RemoteReaderSiteInfoSubscription.swift */, - 0CE4E8242DC027AC00056DD9 /* RemoteSubscriber.swift */, 4A68E3DE29407100004AC3DC /* RemoteReaderTopic.swift */, 74E2295D1F1E777B0085F7F2 /* RemoteSharingButton.swift */, 7430C9C81F192F260051B8E6 /* RemoteSourcePostAttribution.h */, @@ -1953,6 +1962,7 @@ 74A44DC91F13C533006CD8F4 /* NotificationSyncServiceRemote.swift */, 4625B96B253A357500C04AAD /* PageLayoutServiceRemote.swift */, 74D67F051F1528470010C5ED /* PeopleServiceRemote.swift */, + 0CE311BC2DCBB52C003AADB3 /* SubscribersServiceRemote.swift */, 3F3195AB266FF91100397EE7 /* Plans */, C79719682679007B0072F984 /* Plugin Management */, E1BD95141FD5A2B800CD5CE3 /* PluginDirectoryServiceRemote.swift */, @@ -2031,6 +2041,7 @@ 3F3195AC266FF94B00397EE7 /* ZendeskMetadata.swift */, 4AE278432B2FAF6200E4D9B1 /* HTTPProtocolHelpers.swift */, 0CCD4C5B2C41700B00B53F9A /* UIDevice+Extensions.swift */, + 0CD5D3DC2DCE4F5500B4E679 /* StringCodingKey.swift */, ); path = Utility; sourceTree = ""; @@ -2081,6 +2092,7 @@ 74A44DD31F13C6D8006CD8F4 /* PushAuthenticationServiceRemoteTests.swift */, 74FC6F3A1F191BB400112505 /* NotificationSyncServiceRemoteTests.swift */, 74D67F091F15C24C0010C5ED /* PeopleServiceRemoteTests.swift */, + 0CE311BE2DCBB588003AADB3 /* SubscribersServiceRemoteTests.swift */, 7433BC031EFC4556002D9E92 /* PlanServiceRemoteTests.swift */, E13EE14B1F332C4400C15787 /* PluginServiceRemoteTests.swift */, E1E89C691FD6BDB1006E7A33 /* PluginDirectoryTests.swift */, @@ -2547,6 +2559,9 @@ 74D67F0C1F15C2D70010C5ED /* site-roles-auth-failure.json */, 74D67F0D1F15C2D70010C5ED /* site-roles-bad-json-failure.json */, 0C8069A62DC03E85008DFC2F /* site-subscribers-response.json */, + 0CE311C42DCBB970003AADB3 /* site-subscriber-stats-response.json */, + 0CE311C62DCBBA01003AADB3 /* site-subscriber-get-details-response.json */, + 0CD5D3DE2DCE50D900B4E679 /* site-subscriber-get-details-response-invalid-country.json */, 74D67F0E1F15C2D70010C5ED /* site-roles-success.json */, D8DB404121EF22B500B8238E /* site-segments-multiple.json */, D813437721F6D7DC0060D99A /* site-segments-single.json */, @@ -3073,6 +3088,7 @@ 74C473CD1EF336BD009918F2 /* site-active-purchases-bad-json-failure.json in Resources */, 436D5645211B801100CEAA33 /* validate-domain-contact-information-response-success.json in Resources */, FE5096652A309DEE00DDD071 /* jetpack-social-with-publicize.json in Resources */, + 0CD5D3DF2DCE50D900B4E679 /* site-subscriber-get-details-response-invalid-country.json in Resources */, 74D67F351F15C3740010C5ED /* site-users-delete-not-member-failure.json in Resources */, E1E89C681FD6B2E9006E7A33 /* plugin-directory-jetpack.json in Resources */, 74D67F201F15C3240010C5ED /* people-validate-invitation-failure.json in Resources */, @@ -3121,6 +3137,7 @@ E1787DB0200E564B004CB3AF /* timezones.json in Resources */, 93BD275E1EE73442002BB00B /* me-bad-json-failure.json in Resources */, FFE247AF20C891E6002DF3A2 /* WordPressComOAuthWrongPasswordFail.json in Resources */, + 0CE311C52DCBB970003AADB3 /* site-subscriber-stats-response.json in Resources */, F194E1252417EE7E00874408 /* atomic-get-auth-cookie-success.json in Resources */, 731BA83A21DED358000FDFCD /* site-creation-success.json in Resources */, FEFFD99726C158F400F34231 /* share-app-content-success.json in Resources */, @@ -3147,6 +3164,7 @@ B04D8C052BB7895A002717A2 /* stats-insight-followers.json in Resources */, 74D67F3B1F15C3740010C5ED /* site-viewers-delete-success.json in Resources */, 93BD275F1EE73442002BB00B /* me-sites-auth-failure.json in Resources */, + 0CE311C72DCBBA01003AADB3 /* site-subscriber-get-details-response.json in Resources */, 74585BA11F0D6F5300E7E667 /* domain-service-empty.json in Resources */, 74C473C51EF33242009918F2 /* site-active-purchases-two-active-success.json in Resources */, 4A3239682B74319400EFD2A8 /* self-hosted-plugins-install.json in Resources */, @@ -3446,6 +3464,7 @@ 4624222D2548BA0F002B8A12 /* RemoteSiteDesign.swift in Sources */, 74D67F061F1528470010C5ED /* PeopleServiceRemote.swift in Sources */, 0C938A2B2C416DE0009BA7B2 /* DisplayableImageHelper.m in Sources */, + 0CE311BD2DCBB52C003AADB3 /* SubscribersServiceRemote.swift in Sources */, 98DC787522BAEBF200267279 /* StatsAllAnnualInsight.swift in Sources */, 740B23C31F17EE8000067A2A /* RemotePostCategory.m in Sources */, 8B2F4BF124ACE3C30056C08A /* RemoteReaderInterest.swift in Sources */, @@ -3459,6 +3478,7 @@ C7A09A52284104DB003096ED /* QRLoginServiceRemote.swift in Sources */, 4A68E3DD294070A7004AC3DC /* RemoteReaderSite.swift in Sources */, 40AB1ADA200FED25009B533D /* PluginDirectoryFeedPage.swift in Sources */, + 0CD5D3DD2DCE4F5500B4E679 /* StringCodingKey.swift in Sources */, 436D56352118D85800CEAA33 /* WPCountry.swift in Sources */, 74A44DCB1F13C533006CD8F4 /* NotificationSettingsServiceRemote.swift in Sources */, FAD1344525908F5F00A8FEB1 /* JetpackBackupServiceRemote.swift in Sources */, @@ -3498,7 +3518,6 @@ 3FD634F32BC3AD6200CEDF5E /* Result+Callback.swift in Sources */, B5A4822E20AC6C1A009D95F6 /* WPKitLogging.m in Sources */, 3FE2E97C2BC3A332002CA2E1 /* WordPressComRestApi.swift in Sources */, - 0CE4E8252DC027AC00056DD9 /* RemoteSubscriber.swift in Sources */, FE6C673C2BB739950083ECAB /* NSAttributedString+extensions.swift in Sources */, 7430C9A61F1927180051B8E6 /* ReaderSiteServiceRemote.m in Sources */, FEE4EF57272FDD4B003CDA3C /* RemoteCommentV2.swift in Sources */, @@ -3649,6 +3668,7 @@ 4A1DEF46293051C600322608 /* LoggingTests.m in Sources */, 930999521F1658F800F006A1 /* ThemeServiceRemoteTests.m in Sources */, 8BE67ED324AD05D3004DB4C9 /* Decodable+DictionaryTests.swift in Sources */, + 0CE311BF2DCBB588003AADB3 /* SubscribersServiceRemoteTests.swift in Sources */, FEE48EF62A4B3602008A48E0 /* BlogServiceRemote+ActiveFeaturesTests.swift in Sources */, 74B335DA1F06F3D60053A184 /* WordPressComRestApiTests.swift in Sources */, FA87FE0724EB39C4003FBEE3 /* ReaderPostServiceRemote+SubscriptionTests.swift in Sources */,