From 58092a4a0a1482782f4b49a7325deab9656276cc Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Fri, 18 Jul 2025 14:24:44 -0400 Subject: [PATCH 01/23] Add startDate, timeZone, and fields parameter to SattsServiceRemoteV2.getData --- .../Stats/StatsSubscribersSummaryData.swift | 2 +- .../StatsFileDownloadsTimeIntervalData.swift | 2 +- .../StatsSummaryTimeIntervalData.swift | 4 +- .../Services/StatsServiceRemoteV2.swift | 55 ++++++++++++++----- 4 files changed, 45 insertions(+), 18 deletions(-) diff --git a/Sources/WordPressKit/Models/Stats/StatsSubscribersSummaryData.swift b/Sources/WordPressKit/Models/Stats/StatsSubscribersSummaryData.swift index aece6030..162bffe9 100644 --- a/Sources/WordPressKit/Models/Stats/StatsSubscribersSummaryData.swift +++ b/Sources/WordPressKit/Models/Stats/StatsSubscribersSummaryData.swift @@ -78,7 +78,7 @@ extension StatsSubscribersSummaryData: StatsTimeIntervalData { } } - public static func queryProperties(with date: Date, period: StatsPeriodUnit, maxCount: Int) -> [String: String] { + public static func queryProperties(period: StatsPeriodUnit, maxCount: Int) -> [String: String] { return ["quantity": String(maxCount), "unit": period.stringValue] } } diff --git a/Sources/WordPressKit/Models/Stats/Time Interval/StatsFileDownloadsTimeIntervalData.swift b/Sources/WordPressKit/Models/Stats/Time Interval/StatsFileDownloadsTimeIntervalData.swift index da268e47..4863f836 100644 --- a/Sources/WordPressKit/Models/Stats/Time Interval/StatsFileDownloadsTimeIntervalData.swift +++ b/Sources/WordPressKit/Models/Stats/Time Interval/StatsFileDownloadsTimeIntervalData.swift @@ -35,7 +35,7 @@ extension StatsFileDownloadsTimeIntervalData: StatsTimeIntervalData { return "stats/file-downloads" } - public static func queryProperties(with date: Date, period: StatsPeriodUnit, maxCount: Int) -> [String: String] { + public static func queryProperties(period: StatsPeriodUnit, maxCount: Int) -> [String: String] { // num = number of periods to include in the query. default: 1. return ["num": String(maxCount)] } diff --git a/Sources/WordPressKit/Models/Stats/Time Interval/StatsSummaryTimeIntervalData.swift b/Sources/WordPressKit/Models/Stats/Time Interval/StatsSummaryTimeIntervalData.swift index acefd4b7..553e137e 100644 --- a/Sources/WordPressKit/Models/Stats/Time Interval/StatsSummaryTimeIntervalData.swift +++ b/Sources/WordPressKit/Models/Stats/Time Interval/StatsSummaryTimeIntervalData.swift @@ -59,7 +59,7 @@ extension StatsSummaryTimeIntervalData: StatsTimeIntervalData { return "stats/visits" } - public static func queryProperties(with date: Date, period: StatsPeriodUnit, maxCount: Int) -> [String: String] { + public static func queryProperties(period: StatsPeriodUnit, maxCount: Int) -> [String: String] { return ["unit": period.stringValue, "quantity": String(maxCount), "stat_fields": "views,visitors,comments,likes"] @@ -234,7 +234,7 @@ extension StatsLikesSummaryTimeIntervalData: StatsTimeIntervalData { return "stats/visits" } - public static func queryProperties(with date: Date, period: StatsPeriodUnit, maxCount: Int) -> [String: String] { + public static func queryProperties(period: StatsPeriodUnit, maxCount: Int) -> [String: String] { return ["unit": period.stringValue, "quantity": String(maxCount), "stat_fields": "likes"] diff --git a/Sources/WordPressKit/Services/StatsServiceRemoteV2.swift b/Sources/WordPressKit/Services/StatsServiceRemoteV2.swift index 02b9ce94..edda95c4 100644 --- a/Sources/WordPressKit/Services/StatsServiceRemoteV2.swift +++ b/Sources/WordPressKit/Services/StatsServiceRemoteV2.swift @@ -95,26 +95,53 @@ open class StatsServiceRemoteV2: ServiceRemoteWordPressComREST { /// - 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**. + /// - endDate: 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`. + /// - timeZone: The time zone in which the dates are represented. /// - 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, - endingOn: Date, - limit: Int = 10, - completion: @escaping ((TimeStatsType?, Error?) -> Void)) { + open func getData( + period: StatsPeriodUnit, + unit: StatsPeriodUnit? = nil, + startDate: Date? = nil, + endDate: Date, + timeZone: TimeZone? = nil, + limit: Int = 10, + fields: [String]? = nil, + completion: @escaping ((TimeStatsType?, Error?) -> Void) + ) { let pathComponent = TimeStatsType.pathComponent let path = self.path(forEndpoint: "sites/\(siteID)/\(pathComponent)/", withVersion: ._1_1) - let staticProperties = ["period": period.stringValue, - "unit": unit?.stringValue ?? period.stringValue, - "date": periodDataQueryDateFormatter.string(from: endingOn)] as [String: AnyObject] + func formattedDate(_ date: Date) -> String { + guard let timeZone else { + // For backward-compatibility, use the existing periodDataQueryDateFormatter + // with the current time zone. + return periodDataQueryDateFormatter.string(from: date) + } + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "yyyy-MM-dd" + formatter.timeZone = timeZone + return formatter.string(from: date) + } - let classProperties = TimeStatsType.queryProperties(with: endingOn, period: unit ?? period, maxCount: limit) as [String: AnyObject] + var properties = [ + "period": period.stringValue, + "unit": unit?.stringValue ?? period.stringValue, + "date": formattedDate(endDate) + ] as [String: Any] - let properties = staticProperties.merging(classProperties) { val1, _ in - return val1 + for (key, value) in TimeStatsType.queryProperties(period: unit ?? period, maxCount: limit) { + properties[key] = value + } + + if let startDate { + properties["period"] = nil + properties["start_date"] = formattedDate(startDate) + } + if let fields { + properties["stat_fields"] = fields.joined(separator: ",") } wordPressComRESTAPI.get(path, parameters: properties, success: { [weak self] (response, _) in @@ -361,7 +388,7 @@ public protocol StatsTimeIntervalData { 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] + static func queryProperties(period: StatsPeriodUnit, maxCount: Int) -> [String: String] } extension StatsTimeIntervalData { @@ -370,7 +397,7 @@ extension StatsTimeIntervalData { return nil } - public static func queryProperties(with date: Date, period: StatsPeriodUnit, maxCount: Int) -> [String: String] { + public static func queryProperties(period: StatsPeriodUnit, maxCount: Int) -> [String: String] { return ["max": String(maxCount)] } From 8ad283deb68f34173187d9063d671baf9a85cdcd Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Fri, 18 Jul 2025 14:37:18 -0400 Subject: [PATCH 02/23] Add StatsPeriodUnit.hour --- .../Stats/StatsSubscribersSummaryData.swift | 10 +++++++ .../StatsSummaryTimeIntervalData.swift | 11 ++++++++ .../Services/StatsServiceRemoteV2.swift | 26 +++++++------------ 3 files changed, 30 insertions(+), 17 deletions(-) diff --git a/Sources/WordPressKit/Models/Stats/StatsSubscribersSummaryData.swift b/Sources/WordPressKit/Models/Stats/StatsSubscribersSummaryData.swift index 162bffe9..8aa77983 100644 --- a/Sources/WordPressKit/Models/Stats/StatsSubscribersSummaryData.swift +++ b/Sources/WordPressKit/Models/Stats/StatsSubscribersSummaryData.swift @@ -17,6 +17,13 @@ extension StatsSubscribersSummaryData: StatsTimeIntervalData { return "stats/subscribers" } + static var hourlyDateFormatter: DateFormatter { + let df = DateFormatter() + df.locale = Locale(identifier: "en_US_POS") + df.dateFormat = "yyyy-MM-dd HH:mm:ss" + return df + } + static var dateFormatter: DateFormatter = { let df = DateFormatter() df.locale = Locale(identifier: "en_US_POS") @@ -71,6 +78,9 @@ extension StatsSubscribersSummaryData: StatsTimeIntervalData { 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: diff --git a/Sources/WordPressKit/Models/Stats/Time Interval/StatsSummaryTimeIntervalData.swift b/Sources/WordPressKit/Models/Stats/Time Interval/StatsSummaryTimeIntervalData.swift index 553e137e..c5a3a0a9 100644 --- a/Sources/WordPressKit/Models/Stats/Time Interval/StatsSummaryTimeIntervalData.swift +++ b/Sources/WordPressKit/Models/Stats/Time Interval/StatsSummaryTimeIntervalData.swift @@ -1,4 +1,5 @@ @frozen public enum StatsPeriodUnit: Int { + case hour case day case week case month @@ -177,6 +178,9 @@ private extension StatsSummaryData { 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: @@ -184,6 +188,13 @@ private extension StatsSummaryData { } } + static var hourlyDateFormatter: DateFormatter { + let df = DateFormatter() + df.locale = Locale(identifier: "en_US_POS") + df.dateFormat = "yyyy-MM-dd HH:mm:ss" + return df + } + static var regularDateFormatter: DateFormatter { let df = DateFormatter() df.locale = Locale(identifier: "en_US_POS") diff --git a/Sources/WordPressKit/Services/StatsServiceRemoteV2.swift b/Sources/WordPressKit/Services/StatsServiceRemoteV2.swift index edda95c4..acb6cb14 100644 --- a/Sources/WordPressKit/Services/StatsServiceRemoteV2.swift +++ b/Sources/WordPressKit/Services/StatsServiceRemoteV2.swift @@ -98,14 +98,12 @@ open class StatsServiceRemoteV2: ServiceRemoteWordPressComREST { /// - endDate: 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`. - /// - timeZone: The time zone in which the dates are represented. /// - limit: Limit of how many objects you want returned for your query. Default is `10`. `0` means no limit. open func getData( period: StatsPeriodUnit, unit: StatsPeriodUnit? = nil, startDate: Date? = nil, endDate: Date, - timeZone: TimeZone? = nil, limit: Int = 10, fields: [String]? = nil, completion: @escaping ((TimeStatsType?, Error?) -> Void) @@ -113,23 +111,10 @@ open class StatsServiceRemoteV2: ServiceRemoteWordPressComREST { let pathComponent = TimeStatsType.pathComponent let path = self.path(forEndpoint: "sites/\(siteID)/\(pathComponent)/", withVersion: ._1_1) - func formattedDate(_ date: Date) -> String { - guard let timeZone else { - // For backward-compatibility, use the existing periodDataQueryDateFormatter - // with the current time zone. - return periodDataQueryDateFormatter.string(from: date) - } - let formatter = DateFormatter() - formatter.locale = Locale(identifier: "en_US_POSIX") - formatter.dateFormat = "yyyy-MM-dd" - formatter.timeZone = timeZone - return formatter.string(from: date) - } - var properties = [ "period": period.stringValue, "unit": unit?.stringValue ?? period.stringValue, - "date": formattedDate(endDate) + "date": periodDataQueryDateFormatter.string(from: endDate) ] as [String: Any] for (key, value) in TimeStatsType.queryProperties(period: unit ?? period, maxCount: limit) { @@ -138,7 +123,7 @@ open class StatsServiceRemoteV2: ServiceRemoteWordPressComREST { if let startDate { properties["period"] = nil - properties["start_date"] = formattedDate(startDate) + properties["start_date"] = periodDataQueryDateFormatter.string(from: startDate) } if let fields { properties["stat_fields"] = fields.joined(separator: ",") @@ -304,6 +289,9 @@ extension StatsServiceRemoteV2 { 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: @@ -425,6 +413,8 @@ extension StatsTimeIntervalData { public extension StatsPeriodUnit { var stringValue: String { switch self { + case .hour: + return "hour" case .day: return "day" case .week: @@ -438,6 +428,8 @@ public extension StatsPeriodUnit { init?(string: String) { switch string { + case "hour": + self = .hour case "day": self = .day case "week": From 8c33cf7430b9667aee6b83662d894ad34dc8495b Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Fri, 18 Jul 2025 14:46:24 -0400 Subject: [PATCH 03/23] Revert some of the changes --- Sources/WordPressKit/Services/StatsServiceRemoteV2.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Sources/WordPressKit/Services/StatsServiceRemoteV2.swift b/Sources/WordPressKit/Services/StatsServiceRemoteV2.swift index acb6cb14..c276be5f 100644 --- a/Sources/WordPressKit/Services/StatsServiceRemoteV2.swift +++ b/Sources/WordPressKit/Services/StatsServiceRemoteV2.swift @@ -95,15 +95,15 @@ open class StatsServiceRemoteV2: ServiceRemoteWordPressComREST { /// - 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`. - /// - endDate: Date on which the `period` for which data you're interested in **is ending**. + /// - 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( - period: StatsPeriodUnit, + for period: StatsPeriodUnit, unit: StatsPeriodUnit? = nil, startDate: Date? = nil, - endDate: Date, + endingOn: Date, limit: Int = 10, fields: [String]? = nil, completion: @escaping ((TimeStatsType?, Error?) -> Void) @@ -114,7 +114,7 @@ open class StatsServiceRemoteV2: ServiceRemoteWordPressComREST { var properties = [ "period": period.stringValue, "unit": unit?.stringValue ?? period.stringValue, - "date": periodDataQueryDateFormatter.string(from: endDate) + "date": periodDataQueryDateFormatter.string(from: endingOn) ] as [String: Any] for (key, value) in TimeStatsType.queryProperties(period: unit ?? period, maxCount: limit) { From b17f5d865495bd34a25c5955072439d620c24097 Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Fri, 18 Jul 2025 15:48:59 -0400 Subject: [PATCH 04/23] Fix release build error --- Sources/WordPressShared/Dictionary+Helpers.swift | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/Sources/WordPressShared/Dictionary+Helpers.swift b/Sources/WordPressShared/Dictionary+Helpers.swift index 76d50ad5..1bf8bb0e 100644 --- a/Sources/WordPressShared/Dictionary+Helpers.swift +++ b/Sources/WordPressShared/Dictionary+Helpers.swift @@ -12,13 +12,14 @@ extension Dictionary { /// - Returns: Value as a String (when possible!) /// func valueAsString(forKey key: Key) -> String? { - let value = self[key] - switch value { - case let string as String: + guard let value = self[key] else { + return nil + } + if let string = value as? String { return string - case let number as NSNumber: + } else if let number = value as? NSNumber { return number.description - default: + } else { return nil } } From ca75e34cee173868ca8e34348f8ff093e6ccf3b3 Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Fri, 18 Jul 2025 15:53:48 -0400 Subject: [PATCH 05/23] Update build --- Package.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Package.swift b/Package.swift index 14da2427..8904a961 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/20895757/WordPressKit.zip", - checksum: "b08eaf182f0399303aadccb1a6dad6cad294a9c8d123d920889b15950c85e08f" + url: "https://github.com/user-attachments/files/21322294/WordPressKit.zip", + checksum: "1e00efe677045ce0fa0ace9998a8768b83afa3deb3eccb5faed2d17a0d41b364" ), ] ) From 064b128001ffa64447b2d839d873191766232ed5 Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Sat, 19 Jul 2025 10:52:10 -0400 Subject: [PATCH 06/23] Fix parsing for hourly data --- .../StatsSummaryTimeIntervalData.swift | 2 +- .../Services/StatsServiceRemoteV2.swift | 13 ++++++++++--- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/Sources/WordPressKit/Models/Stats/Time Interval/StatsSummaryTimeIntervalData.swift b/Sources/WordPressKit/Models/Stats/Time Interval/StatsSummaryTimeIntervalData.swift index c5a3a0a9..259bb391 100644 --- a/Sources/WordPressKit/Models/Stats/Time Interval/StatsSummaryTimeIntervalData.swift +++ b/Sources/WordPressKit/Models/Stats/Time Interval/StatsSummaryTimeIntervalData.swift @@ -190,7 +190,7 @@ private extension StatsSummaryData { static var hourlyDateFormatter: DateFormatter { let df = DateFormatter() - df.locale = Locale(identifier: "en_US_POS") + df.locale = Locale(identifier: "en_US_POSIX") df.dateFormat = "yyyy-MM-dd HH:mm:ss" return df } diff --git a/Sources/WordPressKit/Services/StatsServiceRemoteV2.swift b/Sources/WordPressKit/Services/StatsServiceRemoteV2.swift index c276be5f..028fdff7 100644 --- a/Sources/WordPressKit/Services/StatsServiceRemoteV2.swift +++ b/Sources/WordPressKit/Services/StatsServiceRemoteV2.swift @@ -24,6 +24,13 @@ open class StatsServiceRemoteV2: ServiceRemoteWordPressComREST { 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 @@ -129,12 +136,12 @@ open class StatsServiceRemoteV2: ServiceRemoteWordPressComREST { properties["stat_fields"] = fields.joined(separator: ",") } - wordPressComRESTAPI.get(path, parameters: properties, success: { [weak self] (response, _) in + let dateFormatter = period == .hour ? hourlyDateFormatter : periodDataQueryDateFormatter + wordPressComRESTAPI.get(path, parameters: properties, success: { (response, _) in guard - let self, let jsonResponse = response as? [String: AnyObject], let dateString = jsonResponse["date"] as? String, - let date = self.periodDataQueryDateFormatter.date(from: dateString) + let date = dateFormatter.date(from: dateString) else { completion(nil, ResponseError.decodingFailure) return From 5704cbb1a2c2757b1f91c5ba500c14bbebf71261 Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Sat, 19 Jul 2025 10:54:53 -0400 Subject: [PATCH 07/23] Fetch post counts --- .../StatsSummaryTimeIntervalData.swift | 51 +++++++++++++------ 1 file changed, 35 insertions(+), 16 deletions(-) diff --git a/Sources/WordPressKit/Models/Stats/Time Interval/StatsSummaryTimeIntervalData.swift b/Sources/WordPressKit/Models/Stats/Time Interval/StatsSummaryTimeIntervalData.swift index 259bb391..dd83d4fc 100644 --- a/Sources/WordPressKit/Models/Stats/Time Interval/StatsSummaryTimeIntervalData.swift +++ b/Sources/WordPressKit/Models/Stats/Time Interval/StatsSummaryTimeIntervalData.swift @@ -39,19 +39,22 @@ public struct StatsSummaryData { public let visitorsCount: Int public let likesCount: Int public let commentsCount: Int + public let postsCount: Int? public init(period: StatsPeriodUnit, periodStartDate: Date, viewsCount: Int, visitorsCount: Int, likesCount: Int, - commentsCount: Int) { + commentsCount: Int, + postsCount: Int?) { self.period = period self.periodStartDate = periodStartDate self.viewsCount = viewsCount self.visitorsCount = visitorsCount self.likesCount = likesCount self.commentsCount = commentsCount + self.postsCount = postsCount } } @@ -101,13 +104,18 @@ extension StatsSummaryTimeIntervalData: StatsTimeIntervalData { 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) } + self.summaryData = data.compactMap { + StatsSummaryData( + dataArray: $0, + period: unit ?? period, + periodIndex: periodIndex, + viewsIndex: viewsIndex, + visitorsIndex: visitorsIndex, + likesIndex: likesIndex, + commentsIndex: commentsIndex, + postsIndex: fieldsArray.firstIndex(of: "posts") + ) + } } } @@ -118,7 +126,8 @@ private extension StatsSummaryData { viewsIndex: Int?, visitorsIndex: Int?, likesIndex: Int?, - commentsIndex: Int?) { + commentsIndex: Int?, + postsIndex: Int?) { guard let periodString = dataArray[periodIndex] as? String, @@ -130,6 +139,7 @@ private extension StatsSummaryData { let visitorsCount: Int let likesCount: Int let commentsCount: Int + var postsCount: Int? if let viewsIndex = viewsIndex { guard let count = dataArray[viewsIndex] as? Int else { @@ -167,6 +177,10 @@ private extension StatsSummaryData { commentsCount = 0 } + if let postsIndex { + postsCount = dataArray[postsIndex] as? Int + } + self.period = period self.periodStartDate = periodStart @@ -174,6 +188,7 @@ private extension StatsSummaryData { self.visitorsCount = visitorsCount self.likesCount = likesCount self.commentsCount = commentsCount + self.postsCount = postsCount } static func parsedDate(from dateString: String, for period: StatsPeriodUnit) -> Date? { @@ -271,12 +286,16 @@ extension StatsLikesSummaryTimeIntervalData: StatsTimeIntervalData { 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) } + self.summaryData = data.compactMap { + StatsSummaryData( + dataArray: $0, + period: unit ?? period, + periodIndex: periodIndex, + viewsIndex: nil, + visitorsIndex: nil, + likesIndex: likesIndex, + commentsIndex: nil, postsIndex: nil + ) + } } } From e146c87c830bc862c9bd31774ccf1771a822c7ae Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Sat, 19 Jul 2025 10:58:27 -0400 Subject: [PATCH 08/23] Update build --- Package.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Package.swift b/Package.swift index 8904a961..0670534c 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/21322294/WordPressKit.zip", - checksum: "1e00efe677045ce0fa0ace9998a8768b83afa3deb3eccb5faed2d17a0d41b364" + url: "https://github.com/user-attachments/files/21328342/WordPressKit.zip", + checksum: "fb23d0f4768e6a3017f96e220f3e54b1be264cab8161887d3b16109e32d2799f" ), ] ) From 7e769695f6d448c17604cd54de5837d98c4bbbf8 Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Sat, 19 Jul 2025 11:39:41 -0400 Subject: [PATCH 09/23] make values in StatsSummaryData optional (they are for hourly data) --- .../StatsSummaryTimeIntervalData.swift | 110 +++++------------- .../Tests/StatsRemoteV2Tests.swift | 12 +- 2 files changed, 38 insertions(+), 84 deletions(-) diff --git a/Sources/WordPressKit/Models/Stats/Time Interval/StatsSummaryTimeIntervalData.swift b/Sources/WordPressKit/Models/Stats/Time Interval/StatsSummaryTimeIntervalData.swift index dd83d4fc..84912163 100644 --- a/Sources/WordPressKit/Models/Stats/Time Interval/StatsSummaryTimeIntervalData.swift +++ b/Sources/WordPressKit/Models/Stats/Time Interval/StatsSummaryTimeIntervalData.swift @@ -35,18 +35,18 @@ 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 let viewsCount: Int? + public let visitorsCount: Int? + public let likesCount: Int? + public let commentsCount: Int? public let postsCount: Int? public init(period: StatsPeriodUnit, periodStartDate: Date, - viewsCount: Int, - visitorsCount: Int, - likesCount: Int, - commentsCount: Int, + viewsCount: Int?, + visitorsCount: Int?, + likesCount: Int?, + commentsCount: Int?, postsCount: Int?) { self.period = period self.periodStartDate = periodStartDate @@ -91,14 +91,8 @@ extension StatsSummaryTimeIntervalData: StatsTimeIntervalData { // [["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 + guard let periodIndex = fieldsArray.firstIndex(of: "period") else { + return nil } self.period = period @@ -109,10 +103,10 @@ extension StatsSummaryTimeIntervalData: StatsTimeIntervalData { dataArray: $0, period: unit ?? period, periodIndex: periodIndex, - viewsIndex: viewsIndex, - visitorsIndex: visitorsIndex, - likesIndex: likesIndex, - commentsIndex: commentsIndex, + viewsIndex: fieldsArray.firstIndex(of: "views"), + visitorsIndex: fieldsArray.firstIndex(of: "visitors"), + likesIndex: fieldsArray.firstIndex(of: "likes"), + commentsIndex: fieldsArray.firstIndex(of: "comments"), postsIndex: fieldsArray.firstIndex(of: "posts") ) } @@ -120,75 +114,35 @@ extension StatsSummaryTimeIntervalData: StatsTimeIntervalData { } private extension StatsSummaryData { - init?(dataArray: [Any], - period: StatsPeriodUnit, - periodIndex: Int, - viewsIndex: Int?, - visitorsIndex: Int?, - likesIndex: Int?, - commentsIndex: Int?, - postsIndex: Int?) { - + init?( + dataArray: [Any], + period: StatsPeriodUnit, + periodIndex: Int, + viewsIndex: Int?, + visitorsIndex: Int?, + likesIndex: Int?, + commentsIndex: Int?, + postsIndex: 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 - var postsCount: Int? - - if let viewsIndex = viewsIndex { - guard let count = dataArray[viewsIndex] as? Int else { - return nil - } - viewsCount = count - } else { - viewsCount = 0 - } - - if let visitorsIndex = visitorsIndex { - guard let count = dataArray[visitorsIndex] as? Int else { - return nil - } - visitorsCount = count - } else { - visitorsCount = 0 - } - - if let likesIndex = likesIndex { - guard let count = dataArray[likesIndex] as? Int else { - return nil - } - likesCount = count - } else { - likesCount = 0 - } - - if let commentsIndex = commentsIndex { - guard let count = dataArray[commentsIndex] as? Int else { - return nil - } - commentsCount = count - } else { - commentsCount = 0 - } - - if let postsIndex { - postsCount = dataArray[postsIndex] as? Int + func getValue(at index: Int?) -> Int? { + guard let index else { return nil } + return dataArray[index] as? Int } self.period = period self.periodStartDate = periodStart - self.viewsCount = viewsCount - self.visitorsCount = visitorsCount - self.likesCount = likesCount - self.commentsCount = commentsCount - self.postsCount = postsCount + self.viewsCount = getValue(at: viewsIndex) + self.visitorsCount = getValue(at: visitorsIndex) + self.likesCount = getValue(at: likesIndex) + self.commentsCount = getValue(at: commentsIndex) + self.postsCount = getValue(at: postsIndex) } static func parsedDate(from dateString: String, for period: StatsPeriodUnit) -> Date? { diff --git a/Tests/WordPressKitTests/Tests/StatsRemoteV2Tests.swift b/Tests/WordPressKitTests/Tests/StatsRemoteV2Tests.swift index 95476038..8558abbd 100644 --- a/Tests/WordPressKitTests/Tests/StatsRemoteV2Tests.swift +++ b/Tests/WordPressKitTests/Tests/StatsRemoteV2Tests.swift @@ -637,19 +637,19 @@ class StatsRemoteV2Tests: RemoteTestCase, RESTTestable { XCTAssertEqual(summary?.summaryData.count, 10) - XCTAssertEqual(summary?.summaryData[0].viewsCount, 0) - XCTAssertEqual(summary?.summaryData[0].visitorsCount, 0) + XCTAssertNil(summary?.summaryData[0].viewsCount) + XCTAssertNil(summary?.summaryData[0].visitorsCount) XCTAssertEqual(summary?.summaryData[0].likesCount, 72) - XCTAssertEqual(summary?.summaryData[0].commentsCount, 0) + XCTAssertNil(summary?.summaryData[0].commentsCount) let may1 = DateComponents(year: 2018, month: 5, day: 1) let may1Date = Calendar.autoupdatingCurrent.date(from: may1)! XCTAssertEqual(summary?.summaryData[0].periodStartDate, may1Date) - XCTAssertEqual(summary?.summaryData[9].viewsCount, 0) - XCTAssertEqual(summary?.summaryData[9].visitorsCount, 0) + XCTAssertNil(summary?.summaryData[9].viewsCount) + XCTAssertNil(summary?.summaryData[9].visitorsCount) XCTAssertEqual(summary?.summaryData[9].likesCount, 116) - XCTAssertEqual(summary?.summaryData[9].commentsCount, 0) + XCTAssertNil(summary?.summaryData[9].commentsCount) let nineMonthsFromMay1 = Calendar.autoupdatingCurrent.date(byAdding: .month, value: 9, to: may1Date)! From 8bf608221d0f6fa8ff2a4f09ba5460e4fa09075a Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Sat, 19 Jul 2025 11:41:53 -0400 Subject: [PATCH 10/23] Update build --- Package.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Package.swift b/Package.swift index 0670534c..973c02ca 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/21328342/WordPressKit.zip", - checksum: "fb23d0f4768e6a3017f96e220f3e54b1be264cab8161887d3b16109e32d2799f" + url: "https://github.com/user-attachments/files/21328509/WordPressKit.zip", + checksum: "e01a5e91e822b84058346b163663c88ee2ee50a9b1614804dbc0429567f00835" ), ] ) From 0a0dac0ad90e281078e58c5db3d3c23fd879582d Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Sat, 19 Jul 2025 13:34:26 -0400 Subject: [PATCH 11/23] Add new StatsSiteStats to avoid changing previous APIs --- Package.swift | 4 +- .../Stats/StatsSubscribersSummaryData.swift | 2 +- .../StatsFileDownloadsTimeIntervalData.swift | 2 +- .../Stats/Time Interval/StatsSiteStats.swift | 108 +++++++++++ .../StatsSummaryTimeIntervalData.swift | 150 ++++++++------- .../Services/StatsServiceRemoteV2.swift | 42 ++-- .../Mock Data/stats-visits-hourly.json | 181 ++++++++++++++++++ .../Tests/StatsRemoteV2Tests.swift | 70 ++++--- WordPressKit.xcodeproj/project.pbxproj | 8 + 9 files changed, 451 insertions(+), 116 deletions(-) create mode 100644 Sources/WordPressKit/Models/Stats/Time Interval/StatsSiteStats.swift create mode 100644 Tests/WordPressKitTests/Mock Data/stats-visits-hourly.json diff --git a/Package.swift b/Package.swift index 973c02ca..45287add 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/21328509/WordPressKit.zip", - checksum: "e01a5e91e822b84058346b163663c88ee2ee50a9b1614804dbc0429567f00835" + url: "https://github.com/user-attachments/files/21328988/WordPressKit.zip", + checksum: "963e7189b0b2e207267c94138f2b08dd2d26d3fc5cbedae8b38d49a2c1e7d72b" ), ] ) diff --git a/Sources/WordPressKit/Models/Stats/StatsSubscribersSummaryData.swift b/Sources/WordPressKit/Models/Stats/StatsSubscribersSummaryData.swift index 8aa77983..774f85ad 100644 --- a/Sources/WordPressKit/Models/Stats/StatsSubscribersSummaryData.swift +++ b/Sources/WordPressKit/Models/Stats/StatsSubscribersSummaryData.swift @@ -88,7 +88,7 @@ extension StatsSubscribersSummaryData: StatsTimeIntervalData { } } - public static func queryProperties(period: StatsPeriodUnit, maxCount: Int) -> [String: String] { + public static func queryProperties(with date: Date, period: StatsPeriodUnit, maxCount: Int) -> [String: String] { return ["quantity": String(maxCount), "unit": period.stringValue] } } diff --git a/Sources/WordPressKit/Models/Stats/Time Interval/StatsFileDownloadsTimeIntervalData.swift b/Sources/WordPressKit/Models/Stats/Time Interval/StatsFileDownloadsTimeIntervalData.swift index 4863f836..da268e47 100644 --- a/Sources/WordPressKit/Models/Stats/Time Interval/StatsFileDownloadsTimeIntervalData.swift +++ b/Sources/WordPressKit/Models/Stats/Time Interval/StatsFileDownloadsTimeIntervalData.swift @@ -35,7 +35,7 @@ extension StatsFileDownloadsTimeIntervalData: StatsTimeIntervalData { return "stats/file-downloads" } - public static func queryProperties(period: StatsPeriodUnit, maxCount: Int) -> [String: String] { + 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)] } diff --git a/Sources/WordPressKit/Models/Stats/Time Interval/StatsSiteStats.swift b/Sources/WordPressKit/Models/Stats/Time Interval/StatsSiteStats.swift new file mode 100644 index 00000000..85592755 --- /dev/null +++ b/Sources/WordPressKit/Models/Stats/Time Interval/StatsSiteStats.swift @@ -0,0 +1,108 @@ +import Foundation + +public struct StatsSiteStats { + public var period: StatsPeriodUnit + public var periodEndDate: Date + public let data: [PeriodData] + + enum Metric: String, CaseIterable { + case views + case visitors + case likes + case comments + case posts + } + + public struct PeriodData { + /// Periods date in the site timezone. + var date: Date + var views: Int? + var visitors: Int? + var likes: Int? + var comments: Int? + var posts: Int? + + subscript(metric: Metric) -> Int? { + switch metric { + case .views: views + case .visitors: visitors + case .likes: likes + case .comments: comments + case .posts: posts + } + } + } +} + +extension StatsSiteStats: 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 periodDate = 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: periodDate, + 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/Sources/WordPressKit/Models/Stats/Time Interval/StatsSummaryTimeIntervalData.swift b/Sources/WordPressKit/Models/Stats/Time Interval/StatsSummaryTimeIntervalData.swift index 84912163..758e4502 100644 --- a/Sources/WordPressKit/Models/Stats/Time Interval/StatsSummaryTimeIntervalData.swift +++ b/Sources/WordPressKit/Models/Stats/Time Interval/StatsSummaryTimeIntervalData.swift @@ -35,26 +35,23 @@ 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 let postsCount: Int? + 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?, - postsCount: Int?) { + 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 - self.postsCount = postsCount } } @@ -63,7 +60,7 @@ extension StatsSummaryTimeIntervalData: StatsTimeIntervalData { return "stats/visits" } - public static func queryProperties(period: StatsPeriodUnit, maxCount: Int) -> [String: String] { + 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"] @@ -91,65 +88,99 @@ extension StatsSummaryTimeIntervalData: StatsTimeIntervalData { // [["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") else { - return nil + 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: fieldsArray.firstIndex(of: "views"), - visitorsIndex: fieldsArray.firstIndex(of: "visitors"), - likesIndex: fieldsArray.firstIndex(of: "likes"), - commentsIndex: fieldsArray.firstIndex(of: "comments"), - postsIndex: fieldsArray.firstIndex(of: "posts") - ) - } + 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?, - postsIndex: Int? - ) { + 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 } - func getValue(at index: Int?) -> Int? { - guard let index else { return nil } - return dataArray[index] as? Int + let viewsCount: Int + let visitorsCount: Int + let likesCount: Int + let commentsCount: Int + + if let viewsIndex = viewsIndex { + guard let count = dataArray[viewsIndex] as? Int else { + return nil + } + viewsCount = count + } else { + viewsCount = 0 + } + + if let visitorsIndex = visitorsIndex { + guard let count = dataArray[visitorsIndex] as? Int else { + return nil + } + visitorsCount = count + } else { + visitorsCount = 0 + } + + if let likesIndex = likesIndex { + guard let count = dataArray[likesIndex] as? Int else { + return nil + } + likesCount = count + } else { + likesCount = 0 + } + + if let commentsIndex = commentsIndex { + guard let count = dataArray[commentsIndex] as? Int else { + return nil + } + commentsCount = count + } else { + commentsCount = 0 } self.period = period self.periodStartDate = periodStart - self.viewsCount = getValue(at: viewsIndex) - self.visitorsCount = getValue(at: visitorsIndex) - self.likesCount = getValue(at: likesIndex) - self.commentsCount = getValue(at: commentsIndex) - self.postsCount = getValue(at: postsIndex) + 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: - // Example: "2025-07-17 09:00:00" (in a site timezone) - return self.hourlyDateFormatter.date(from: dateString) + assertionFailure("Unsupported time period") + return nil case .week: return self.weeksDateFormatter.date(from: dateString) case .day, .month, .year: @@ -157,13 +188,6 @@ private extension StatsSummaryData { } } - 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 regularDateFormatter: DateFormatter { let df = DateFormatter() df.locale = Locale(identifier: "en_US_POS") @@ -214,7 +238,7 @@ extension StatsLikesSummaryTimeIntervalData: StatsTimeIntervalData { return "stats/visits" } - public static func queryProperties(period: StatsPeriodUnit, maxCount: Int) -> [String: String] { + public static func queryProperties(with date: Date, period: StatsPeriodUnit, maxCount: Int) -> [String: String] { return ["unit": period.stringValue, "quantity": String(maxCount), "stat_fields": "likes"] @@ -240,16 +264,12 @@ extension StatsLikesSummaryTimeIntervalData: StatsTimeIntervalData { 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, postsIndex: nil - ) - } + self.summaryData = data.compactMap { StatsSummaryData(dataArray: $0, + period: unit ?? period, + periodIndex: periodIndex, + viewsIndex: nil, + visitorsIndex: nil, + likesIndex: likesIndex, + commentsIndex: nil) } } } diff --git a/Sources/WordPressKit/Services/StatsServiceRemoteV2.swift b/Sources/WordPressKit/Services/StatsServiceRemoteV2.swift index 028fdff7..5a77d95c 100644 --- a/Sources/WordPressKit/Services/StatsServiceRemoteV2.swift +++ b/Sources/WordPressKit/Services/StatsServiceRemoteV2.swift @@ -106,34 +106,28 @@ open class StatsServiceRemoteV2: ServiceRemoteWordPressComREST { /// 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, - fields: [String]? = nil, - completion: @escaping ((TimeStatsType?, Error?) -> Void) - ) { + open func getData(for period: StatsPeriodUnit, + unit: StatsPeriodUnit? = nil, + startDate: Date? = nil, + endingOn: Date, + limit: Int = 10, + completion: @escaping ((TimeStatsType?, Error?) -> Void)) { let pathComponent = TimeStatsType.pathComponent let path = self.path(forEndpoint: "sites/\(siteID)/\(pathComponent)/", withVersion: ._1_1) - var properties = [ - "period": period.stringValue, - "unit": unit?.stringValue ?? period.stringValue, - "date": periodDataQueryDateFormatter.string(from: endingOn) - ] as [String: Any] - - for (key, value) in TimeStatsType.queryProperties(period: unit ?? period, maxCount: limit) { - properties[key] = value - } + var staticProperties = ["period": period.stringValue, + "unit": unit?.stringValue ?? period.stringValue, + "date": periodDataQueryDateFormatter.string(from: endingOn)] as [String: AnyObject] if let startDate { - properties["period"] = nil - properties["start_date"] = periodDataQueryDateFormatter.string(from: startDate) + staticProperties["period"] = nil + staticProperties["start_date"] = periodDataQueryDateFormatter.string(from: startDate) as AnyObject } - if let fields { - properties["stat_fields"] = fields.joined(separator: ",") + + let classProperties = TimeStatsType.queryProperties(with: endingOn, period: unit ?? period, maxCount: limit) as [String: AnyObject] + + let properties = staticProperties.merging(classProperties) { val1, _ in + return val1 } let dateFormatter = period == .hour ? hourlyDateFormatter : periodDataQueryDateFormatter @@ -383,7 +377,7 @@ public protocol StatsTimeIntervalData { init?(date: Date, period: StatsPeriodUnit, jsonDictionary: [String: AnyObject]) init?(date: Date, period: StatsPeriodUnit, unit: StatsPeriodUnit?, jsonDictionary: [String: AnyObject]) - static func queryProperties(period: StatsPeriodUnit, maxCount: Int) -> [String: String] + static func queryProperties(with date: Date, period: StatsPeriodUnit, maxCount: Int) -> [String: String] } extension StatsTimeIntervalData { @@ -392,7 +386,7 @@ extension StatsTimeIntervalData { return nil } - public static func queryProperties(period: StatsPeriodUnit, maxCount: Int) -> [String: String] { + public static func queryProperties(with date: Date, period: StatsPeriodUnit, maxCount: Int) -> [String: String] { return ["max": String(maxCount)] } diff --git a/Tests/WordPressKitTests/Mock Data/stats-visits-hourly.json b/Tests/WordPressKitTests/Mock Data/stats-visits-hourly.json new file mode 100644 index 00000000..af72f393 --- /dev/null +++ b/Tests/WordPressKitTests/Mock Data/stats-visits-hourly.json @@ -0,0 +1,181 @@ +{ + "date": "2025-07-18 00:00:00", + "unit": "hour", + "fields": [ + "period", + "views", + "visitors", + "comments", + "likes" + ], + "data": [ + [ + "2025-07-17 00:00:00", + 0, + null, + null, + null + ], + [ + "2025-07-17 01:00:00", + 2, + null, + null, + null + ], + [ + "2025-07-17 02:00:00", + 0, + null, + null, + null + ], + [ + "2025-07-17 03:00:00", + 0, + null, + null, + null + ], + [ + "2025-07-17 04:00:00", + 0, + null, + null, + null + ], + [ + "2025-07-17 05:00:00", + 0, + null, + null, + null + ], + [ + "2025-07-17 06:00:00", + 0, + null, + null, + null + ], + [ + "2025-07-17 07:00:00", + 0, + null, + null, + null + ], + [ + "2025-07-17 08:00:00", + 0, + null, + null, + null + ], + [ + "2025-07-17 09:00:00", + 0, + null, + null, + null + ], + [ + "2025-07-17 10:00:00", + 0, + null, + null, + null + ], + [ + "2025-07-17 11:00:00", + 0, + null, + null, + null + ], + [ + "2025-07-17 12:00:00", + 0, + null, + null, + null + ], + [ + "2025-07-17 13:00:00", + 0, + null, + null, + null + ], + [ + "2025-07-17 14:00:00", + 1, + null, + null, + null + ], + [ + "2025-07-17 15:00:00", + 0, + null, + null, + null + ], + [ + "2025-07-17 16:00:00", + 0, + null, + null, + null + ], + [ + "2025-07-17 17:00:00", + 0, + null, + null, + null + ], + [ + "2025-07-17 18:00:00", + 0, + null, + null, + null + ], + [ + "2025-07-17 19:00:00", + 0, + null, + null, + null + ], + [ + "2025-07-17 20:00:00", + 0, + null, + null, + null + ], + [ + "2025-07-17 21:00:00", + 0, + null, + null, + null + ], + [ + "2025-07-17 22:00:00", + 0, + null, + null, + null + ], + [ + "2025-07-17 23:00:00", + 0, + null, + null, + null + ] + ] +} diff --git a/Tests/WordPressKitTests/Tests/StatsRemoteV2Tests.swift b/Tests/WordPressKitTests/Tests/StatsRemoteV2Tests.swift index 8558abbd..b54e91b1 100644 --- a/Tests/WordPressKitTests/Tests/StatsRemoteV2Tests.swift +++ b/Tests/WordPressKitTests/Tests/StatsRemoteV2Tests.swift @@ -16,6 +16,7 @@ class StatsRemoteV2Tests: RemoteTestCase, RESTTestable { let getClicksMockFilename = "stats-clicks-data.json" let getReferrersMockFilename = "stats-referrer-data.json" let getVisitsDayMockFilename = "stats-visits-day.json" + let getVisitsHourlyMockFilename = "stats-visits-hourly.json" let getVisitsWeekMockFilename = "stats-visits-week.json" let getVisitsMonthMockFilename = "stats-visits-month.json" let getVisitsMonthWithWeekUnitMockFilename = "stats-visits-month-unit-week.json" @@ -436,6 +437,40 @@ class StatsRemoteV2Tests: RemoteTestCase, RESTTestable { waitForExpectations(timeout: timeout, handler: nil) } + func testFetchHourlyData() { + let expect = expectation(description: "It should return only views as other fields are not available") + + stubRemoteResponse(siteVisitsDataEndpoint, filename: getVisitsHourlyMockFilename, contentType: .ApplicationJSON) + + let feb21 = DateComponents(year: 2019, month: 2, day: 21) + let date = Calendar.autoupdatingCurrent.date(from: feb21)! + + remote.getData(for: .day, endingOn: date) { (summary: StatsSummaryTimeIntervalData?, error: Error?) in + XCTAssertNil(error) + XCTAssertNotNil(summary) + + XCTAssertEqual(summary?.summaryData.count, 10) + + XCTAssertEqual(summary?.summaryData[0].viewsCount, 5140) + XCTAssertEqual(summary?.summaryData[0].visitorsCount, 3560) + XCTAssertEqual(summary?.summaryData[0].likesCount, 70) + XCTAssertEqual(summary?.summaryData[0].commentsCount, 1) + + let nineDaysAgo = Calendar.autoupdatingCurrent.date(byAdding: .day, value: -9, to: date)! + XCTAssertEqual(summary?.summaryData[0].periodStartDate, nineDaysAgo) + + XCTAssertEqual(summary?.summaryData[9].viewsCount, 3244) + XCTAssertEqual(summary?.summaryData[9].visitorsCount, 2127) + XCTAssertEqual(summary?.summaryData[9].likesCount, 25) + XCTAssertEqual(summary?.summaryData[9].commentsCount, 0) + XCTAssertEqual(summary?.summaryData[9].periodStartDate, date) + + expect.fulfill() + } + + waitForExpectations(timeout: timeout, handler: nil) + } + func testFetchPostDetail() { let expect = expectation(description: "It should return post detail") @@ -626,34 +661,23 @@ class StatsRemoteV2Tests: RemoteTestCase, RESTTestable { func testLikesForMonth() { let expect = expectation(description: "It should return likes data for a month") - stubRemoteResponse(siteVisitsDataEndpoint, filename: getVisitsMonthMockFilename, contentType: .ApplicationJSON) + stubRemoteResponse(siteVisitsDataEndpoint, filename: getVisitsHourlyMockFilename, contentType: .ApplicationJSON) - let feb21 = DateComponents(year: 2019, month: 2, day: 21) - let date = Calendar.autoupdatingCurrent.date(from: feb21)! + let date = Calendar.current.date(from: DateComponents(year: 2025, month: 7, day: 18))! - remote.getData(for: .month, endingOn: date) { (summary: StatsLikesSummaryTimeIntervalData?, error: Error?) in + remote.getData(for: .hour, unit: .hour, startDate: date, endingOn: date) { (stats: StatsSiteStats?, error: Error?) in XCTAssertNil(error) - XCTAssertNotNil(summary) + XCTAssertNotNil(stats) - XCTAssertEqual(summary?.summaryData.count, 10) + if let data = stats?.data, data.count == 24 { - XCTAssertNil(summary?.summaryData[0].viewsCount) - XCTAssertNil(summary?.summaryData[0].visitorsCount) - XCTAssertEqual(summary?.summaryData[0].likesCount, 72) - XCTAssertNil(summary?.summaryData[0].commentsCount) - - let may1 = DateComponents(year: 2018, month: 5, day: 1) - let may1Date = Calendar.autoupdatingCurrent.date(from: may1)! - XCTAssertEqual(summary?.summaryData[0].periodStartDate, may1Date) - - XCTAssertNil(summary?.summaryData[9].viewsCount) - XCTAssertNil(summary?.summaryData[9].visitorsCount) - XCTAssertEqual(summary?.summaryData[9].likesCount, 116) - XCTAssertNil(summary?.summaryData[9].commentsCount) - - let nineMonthsFromMay1 = Calendar.autoupdatingCurrent.date(byAdding: .month, value: 9, to: may1Date)! - - XCTAssertEqual(summary?.summaryData[9].periodStartDate, nineMonthsFromMay1) + XCTAssertEqual(data[0].views, 0) + XCTAssertNil(data[0].comments) + XCTAssertEqual(data[1].views, 2) + XCTAssertNil(data[1].comments) + } else { + XCTFail("unexpected count") + } expect.fulfill() } diff --git a/WordPressKit.xcodeproj/project.pbxproj b/WordPressKit.xcodeproj/project.pbxproj index e028239c..8a3e42d6 100644 --- a/WordPressKit.xcodeproj/project.pbxproj +++ b/WordPressKit.xcodeproj/project.pbxproj @@ -45,6 +45,8 @@ 0C938A2B2C416DE0009BA7B2 /* DisplayableImageHelper.m in Sources */ = {isa = PBXBuildFile; fileRef = 0C938A292C416DE0009BA7B2 /* DisplayableImageHelper.m */; }; 0C938A2C2C416DE0009BA7B2 /* DisplayableImageHelper.h in Headers */ = {isa = PBXBuildFile; fileRef = 0C938A2A2C416DE0009BA7B2 /* DisplayableImageHelper.h */; settings = {ATTRIBUTES = (Public, ); }; }; 0C9CD7992B9A107E0045BE03 /* RemotePostParameters.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C9CD7982B9A107E0045BE03 /* RemotePostParameters.swift */; }; + 0CAD70302E2C017500EFD4BC /* StatsSiteStats.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CAD702F2E2C017500EFD4BC /* StatsSiteStats.swift */; }; + 0CAD70322E2C0AAF00EFD4BC /* stats-visits-hourly.json in Resources */ = {isa = PBXBuildFile; fileRef = 0CAD70312E2C0AAF00EFD4BC /* stats-visits-hourly.json */; }; 0CB1905E2A2A5E83004D3E80 /* BlazeCampaign.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CB1905D2A2A5E83004D3E80 /* BlazeCampaign.swift */; }; 0CB190612A2A6A13004D3E80 /* blaze-campaigns-search.json in Resources */ = {isa = PBXBuildFile; fileRef = 0CB1905F2A2A6943004D3E80 /* blaze-campaigns-search.json */; }; 0CB190652A2A7569004D3E80 /* BlazeCampaignsSearchResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CB190642A2A7569004D3E80 /* BlazeCampaignsSearchResponse.swift */; }; @@ -829,6 +831,8 @@ 0C938A292C416DE0009BA7B2 /* DisplayableImageHelper.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = DisplayableImageHelper.m; sourceTree = ""; }; 0C938A2A2C416DE0009BA7B2 /* DisplayableImageHelper.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = DisplayableImageHelper.h; sourceTree = ""; }; 0C9CD7982B9A107E0045BE03 /* RemotePostParameters.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemotePostParameters.swift; sourceTree = ""; }; + 0CAD702F2E2C017500EFD4BC /* StatsSiteStats.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatsSiteStats.swift; sourceTree = ""; }; + 0CAD70312E2C0AAF00EFD4BC /* stats-visits-hourly.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "stats-visits-hourly.json"; sourceTree = ""; }; 0CB1905D2A2A5E83004D3E80 /* BlazeCampaign.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlazeCampaign.swift; sourceTree = ""; }; 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 = ""; }; @@ -2274,6 +2278,7 @@ 40819772221E10C900A298E4 /* StatsPublishedPostsTimeIntervalData.swift */, 404057C4221B30400060250C /* StatsSearchTermTimeIntervalData.swift */, 40819777221F00E600A298E4 /* StatsSummaryTimeIntervalData.swift */, + 0CAD702F2E2C017500EFD4BC /* StatsSiteStats.swift */, 404057C8221B789B0060250C /* StatsTopAuthorsTimeIntervalData.swift */, 404057D5221C92660060250C /* StatsTopClicksTimeIntervalData.swift */, 404057D1221C56AB0060250C /* StatsTopCountryTimeIntervalData.swift */, @@ -2604,6 +2609,7 @@ 404057CA221B80BC0060250C /* stats-top-authors.json */, 404057CF221C46780060250C /* stats-videos-data.json */, 4081977A221F153A00A298E4 /* stats-visits-day.json */, + 0CAD70312E2C0AAF00EFD4BC /* stats-visits-hourly.json */, 01438D372B6A35FB0097D60A /* stats-summary.json */, 4081977D221F269A00A298E4 /* stats-visits-month.json */, 01438D342B6A2B2C0097D60A /* stats-visits-month-unit-week.json */, @@ -3208,6 +3214,7 @@ 7403A3001EF06FEB00DED7DC /* me-settings-success.json in Resources */, F4B0F47C2ACB4B74003ABC61 /* get-all-domains-response.json in Resources */, FEE48EF82A4B3E43008A48E0 /* sites-site-active-features.json in Resources */, + 0CAD70322E2C0AAF00EFD4BC /* stats-visits-hourly.json in Resources */, 439A44DE2107CF6F00795ED7 /* site-plans-v3-bad-json-failure.json in Resources */, 74B335EA1F06F76B0053A184 /* xmlrpc-response-getpost.xml in Resources */, FEE4EF6127303361003CDA3C /* comments-v2-edit-context-success.json in Resources */, @@ -3535,6 +3542,7 @@ 0CCD4C5C2C41700B00B53F9A /* UIDevice+Extensions.swift in Sources */, 74A44DD11F13C64B006CD8F4 /* RemoteNotificationSettings.swift in Sources */, FEF7419D28085D89002C4203 /* RemoteBloggingPrompt.swift in Sources */, + 0CAD70302E2C017500EFD4BC /* StatsSiteStats.swift in Sources */, 74DA56331F06EAF000FE9BF4 /* MediaServiceRemoteREST.m in Sources */, 17CD0CC320C58A0D000D9620 /* ReaderSiteSearchServiceRemote.swift in Sources */, 74DA563B1F06EB3000FE9BF4 /* RemoteMedia.m in Sources */, From 9139c2fd8be63fd09515e73ae4af4594d9cf126f Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Sat, 19 Jul 2025 15:04:09 -0400 Subject: [PATCH 12/23] Rename StatsSiteMetricsResponse and make Metric public --- Package.swift | 4 ++-- ...s.swift => StatsSiteMetricsResponse.swift} | 20 +++++++++---------- .../Services/StatsServiceRemoteV2.swift | 7 ++++--- .../Tests/StatsRemoteV2Tests.swift | 2 +- WordPressKit.xcodeproj/project.pbxproj | 8 ++++---- 5 files changed, 21 insertions(+), 20 deletions(-) rename Sources/WordPressKit/Models/Stats/Time Interval/{StatsSiteStats.swift => StatsSiteMetricsResponse.swift} (88%) diff --git a/Package.swift b/Package.swift index 45287add..f840ad3e 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/21328988/WordPressKit.zip", - checksum: "963e7189b0b2e207267c94138f2b08dd2d26d3fc5cbedae8b38d49a2c1e7d72b" + url: "https://github.com/user-attachments/files/21335415/WordPressKit.zip", + checksum: "bed68c5416321ef59721805f4bb31ec5fc198b57f73f35250e9b35a5cb5fbd5e" ), ] ) diff --git a/Sources/WordPressKit/Models/Stats/Time Interval/StatsSiteStats.swift b/Sources/WordPressKit/Models/Stats/Time Interval/StatsSiteMetricsResponse.swift similarity index 88% rename from Sources/WordPressKit/Models/Stats/Time Interval/StatsSiteStats.swift rename to Sources/WordPressKit/Models/Stats/Time Interval/StatsSiteMetricsResponse.swift index 85592755..c991115a 100644 --- a/Sources/WordPressKit/Models/Stats/Time Interval/StatsSiteStats.swift +++ b/Sources/WordPressKit/Models/Stats/Time Interval/StatsSiteMetricsResponse.swift @@ -1,11 +1,11 @@ import Foundation -public struct StatsSiteStats { +public struct StatsSiteMetricsResponse { public var period: StatsPeriodUnit public var periodEndDate: Date public let data: [PeriodData] - enum Metric: String, CaseIterable { + public enum Metric: String, CaseIterable { case views case visitors case likes @@ -15,14 +15,14 @@ public struct StatsSiteStats { public struct PeriodData { /// Periods date in the site timezone. - var date: Date - var views: Int? - var visitors: Int? - var likes: Int? - var comments: Int? - var posts: Int? + public var date: Date + public var views: Int? + public var visitors: Int? + public var likes: Int? + public var comments: Int? + public var posts: Int? - subscript(metric: Metric) -> Int? { + public subscript(metric: Metric) -> Int? { switch metric { case .views: views case .visitors: visitors @@ -34,7 +34,7 @@ public struct StatsSiteStats { } } -extension StatsSiteStats: StatsTimeIntervalData { +extension StatsSiteMetricsResponse: StatsTimeIntervalData { public static var pathComponent: String { "stats/visits" } diff --git a/Sources/WordPressKit/Services/StatsServiceRemoteV2.swift b/Sources/WordPressKit/Services/StatsServiceRemoteV2.swift index 5a77d95c..f3da5db1 100644 --- a/Sources/WordPressKit/Services/StatsServiceRemoteV2.swift +++ b/Sources/WordPressKit/Services/StatsServiceRemoteV2.swift @@ -115,13 +115,15 @@ open class StatsServiceRemoteV2: ServiceRemoteWordPressComREST { 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": periodDataQueryDateFormatter.string(from: endingOn)] as [String: AnyObject] + "date": dateFormatter.string(from: endingOn)] as [String: AnyObject] if let startDate { staticProperties["period"] = nil - staticProperties["start_date"] = periodDataQueryDateFormatter.string(from: startDate) as AnyObject + staticProperties["start_date"] = dateFormatter.string(from: startDate) as AnyObject } let classProperties = TimeStatsType.queryProperties(with: endingOn, period: unit ?? period, maxCount: limit) as [String: AnyObject] @@ -130,7 +132,6 @@ open class StatsServiceRemoteV2: ServiceRemoteWordPressComREST { return val1 } - let dateFormatter = period == .hour ? hourlyDateFormatter : periodDataQueryDateFormatter wordPressComRESTAPI.get(path, parameters: properties, success: { (response, _) in guard let jsonResponse = response as? [String: AnyObject], diff --git a/Tests/WordPressKitTests/Tests/StatsRemoteV2Tests.swift b/Tests/WordPressKitTests/Tests/StatsRemoteV2Tests.swift index b54e91b1..aee30d7b 100644 --- a/Tests/WordPressKitTests/Tests/StatsRemoteV2Tests.swift +++ b/Tests/WordPressKitTests/Tests/StatsRemoteV2Tests.swift @@ -665,7 +665,7 @@ class StatsRemoteV2Tests: RemoteTestCase, RESTTestable { let date = Calendar.current.date(from: DateComponents(year: 2025, month: 7, day: 18))! - remote.getData(for: .hour, unit: .hour, startDate: date, endingOn: date) { (stats: StatsSiteStats?, error: Error?) in + remote.getData(for: .hour, unit: .hour, startDate: date, endingOn: date) { (stats: StatsSiteMetricsResponse?, error: Error?) in XCTAssertNil(error) XCTAssertNotNil(stats) diff --git a/WordPressKit.xcodeproj/project.pbxproj b/WordPressKit.xcodeproj/project.pbxproj index 8a3e42d6..9d51dbf8 100644 --- a/WordPressKit.xcodeproj/project.pbxproj +++ b/WordPressKit.xcodeproj/project.pbxproj @@ -45,7 +45,7 @@ 0C938A2B2C416DE0009BA7B2 /* DisplayableImageHelper.m in Sources */ = {isa = PBXBuildFile; fileRef = 0C938A292C416DE0009BA7B2 /* DisplayableImageHelper.m */; }; 0C938A2C2C416DE0009BA7B2 /* DisplayableImageHelper.h in Headers */ = {isa = PBXBuildFile; fileRef = 0C938A2A2C416DE0009BA7B2 /* DisplayableImageHelper.h */; settings = {ATTRIBUTES = (Public, ); }; }; 0C9CD7992B9A107E0045BE03 /* RemotePostParameters.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C9CD7982B9A107E0045BE03 /* RemotePostParameters.swift */; }; - 0CAD70302E2C017500EFD4BC /* StatsSiteStats.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CAD702F2E2C017500EFD4BC /* StatsSiteStats.swift */; }; + 0CAD70302E2C017500EFD4BC /* StatsSiteMetricsResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CAD702F2E2C017500EFD4BC /* StatsSiteMetricsResponse.swift */; }; 0CAD70322E2C0AAF00EFD4BC /* stats-visits-hourly.json in Resources */ = {isa = PBXBuildFile; fileRef = 0CAD70312E2C0AAF00EFD4BC /* stats-visits-hourly.json */; }; 0CB1905E2A2A5E83004D3E80 /* BlazeCampaign.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CB1905D2A2A5E83004D3E80 /* BlazeCampaign.swift */; }; 0CB190612A2A6A13004D3E80 /* blaze-campaigns-search.json in Resources */ = {isa = PBXBuildFile; fileRef = 0CB1905F2A2A6943004D3E80 /* blaze-campaigns-search.json */; }; @@ -831,7 +831,7 @@ 0C938A292C416DE0009BA7B2 /* DisplayableImageHelper.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = DisplayableImageHelper.m; sourceTree = ""; }; 0C938A2A2C416DE0009BA7B2 /* DisplayableImageHelper.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = DisplayableImageHelper.h; sourceTree = ""; }; 0C9CD7982B9A107E0045BE03 /* RemotePostParameters.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemotePostParameters.swift; sourceTree = ""; }; - 0CAD702F2E2C017500EFD4BC /* StatsSiteStats.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatsSiteStats.swift; sourceTree = ""; }; + 0CAD702F2E2C017500EFD4BC /* StatsSiteMetricsResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatsSiteMetricsResponse.swift; sourceTree = ""; }; 0CAD70312E2C0AAF00EFD4BC /* stats-visits-hourly.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "stats-visits-hourly.json"; sourceTree = ""; }; 0CB1905D2A2A5E83004D3E80 /* BlazeCampaign.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlazeCampaign.swift; sourceTree = ""; }; 0CB1905F2A2A6943004D3E80 /* blaze-campaigns-search.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "blaze-campaigns-search.json"; sourceTree = ""; }; @@ -2278,7 +2278,7 @@ 40819772221E10C900A298E4 /* StatsPublishedPostsTimeIntervalData.swift */, 404057C4221B30400060250C /* StatsSearchTermTimeIntervalData.swift */, 40819777221F00E600A298E4 /* StatsSummaryTimeIntervalData.swift */, - 0CAD702F2E2C017500EFD4BC /* StatsSiteStats.swift */, + 0CAD702F2E2C017500EFD4BC /* StatsSiteMetricsResponse.swift */, 404057C8221B789B0060250C /* StatsTopAuthorsTimeIntervalData.swift */, 404057D5221C92660060250C /* StatsTopClicksTimeIntervalData.swift */, 404057D1221C56AB0060250C /* StatsTopCountryTimeIntervalData.swift */, @@ -3542,7 +3542,7 @@ 0CCD4C5C2C41700B00B53F9A /* UIDevice+Extensions.swift in Sources */, 74A44DD11F13C64B006CD8F4 /* RemoteNotificationSettings.swift in Sources */, FEF7419D28085D89002C4203 /* RemoteBloggingPrompt.swift in Sources */, - 0CAD70302E2C017500EFD4BC /* StatsSiteStats.swift in Sources */, + 0CAD70302E2C017500EFD4BC /* StatsSiteMetricsResponse.swift in Sources */, 74DA56331F06EAF000FE9BF4 /* MediaServiceRemoteREST.m in Sources */, 17CD0CC320C58A0D000D9620 /* ReaderSiteSearchServiceRemote.swift in Sources */, 74DA563B1F06EB3000FE9BF4 /* RemoteMedia.m in Sources */, From 102dc8d277f207b770d32b8d709ed62ba1dd2379 Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Mon, 21 Jul 2025 12:17:42 -0400 Subject: [PATCH 13/23] Pass period --- Package.swift | 4 ++-- Sources/WordPressKit/Services/StatsServiceRemoteV2.swift | 1 - 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/Package.swift b/Package.swift index f840ad3e..8d52c6bc 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/21335415/WordPressKit.zip", - checksum: "bed68c5416321ef59721805f4bb31ec5fc198b57f73f35250e9b35a5cb5fbd5e" + url: "https://github.com/user-attachments/files/21350975/WordPressKit.zip", + checksum: "dc40a4c09af565c16eca2ca0cd110c57ed4e8638ea4baeb2cf1bd124b07d3f8e" ), ] ) diff --git a/Sources/WordPressKit/Services/StatsServiceRemoteV2.swift b/Sources/WordPressKit/Services/StatsServiceRemoteV2.swift index f3da5db1..c1768e58 100644 --- a/Sources/WordPressKit/Services/StatsServiceRemoteV2.swift +++ b/Sources/WordPressKit/Services/StatsServiceRemoteV2.swift @@ -122,7 +122,6 @@ open class StatsServiceRemoteV2: ServiceRemoteWordPressComREST { "date": dateFormatter.string(from: endingOn)] as [String: AnyObject] if let startDate { - staticProperties["period"] = nil staticProperties["start_date"] = dateFormatter.string(from: startDate) as AnyObject } From 392d2d34356ba5ea7ea2e01b32f55cc261fdf769 Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Mon, 21 Jul 2025 12:41:07 -0400 Subject: [PATCH 14/23] Add summarize param --- Package.swift | 4 +- .../StatsTopAuthorsTimeIntervalData.swift | 3 + .../StatsTopPostsTimeIntervalData.swift | 1 + .../Services/StatsServiceRemoteV2.swift | 55 ++++++++++++------- 4 files changed, 41 insertions(+), 22 deletions(-) diff --git a/Package.swift b/Package.swift index 8d52c6bc..e006ad83 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/21350975/WordPressKit.zip", - checksum: "dc40a4c09af565c16eca2ca0cd110c57ed4e8638ea4baeb2cf1bd124b07d3f8e" + url: "https://github.com/user-attachments/files/21352383/WordPressKit.zip", + checksum: "8054b66ecf39b8c23acea6d8c5c6c9f65fda01fcce4da7acf7db4273cb8e9145" ), ] ) diff --git a/Sources/WordPressKit/Models/Stats/Time Interval/StatsTopAuthorsTimeIntervalData.swift b/Sources/WordPressKit/Models/Stats/Time Interval/StatsTopAuthorsTimeIntervalData.swift index 63b90b31..24da95a6 100644 --- a/Sources/WordPressKit/Models/Stats/Time Interval/StatsTopAuthorsTimeIntervalData.swift +++ b/Sources/WordPressKit/Models/Stats/Time Interval/StatsTopAuthorsTimeIntervalData.swift @@ -40,17 +40,20 @@ public struct StatsTopPost { } 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 diff --git a/Sources/WordPressKit/Models/Stats/Time Interval/StatsTopPostsTimeIntervalData.swift b/Sources/WordPressKit/Models/Stats/Time Interval/StatsTopPostsTimeIntervalData.swift index cefd7da3..5f61bf82 100644 --- a/Sources/WordPressKit/Models/Stats/Time Interval/StatsTopPostsTimeIntervalData.swift +++ b/Sources/WordPressKit/Models/Stats/Time Interval/StatsTopPostsTimeIntervalData.swift @@ -59,6 +59,7 @@ private extension StatsTopPost { } self.title = title + self.date = jsonDictionary["date"] as? String self.postID = postID self.postURL = URL(string: url) self.viewsCount = viewsCount diff --git a/Sources/WordPressKit/Services/StatsServiceRemoteV2.swift b/Sources/WordPressKit/Services/StatsServiceRemoteV2.swift index c1768e58..a816cad5 100644 --- a/Sources/WordPressKit/Services/StatsServiceRemoteV2.swift +++ b/Sources/WordPressKit/Services/StatsServiceRemoteV2.swift @@ -8,6 +8,7 @@ open class StatsServiceRemoteV2: ServiceRemoteWordPressComREST { public enum ResponseError: Error { case decodingFailure + case emptySummary } public enum MarkAsSpamResponseError: Error { @@ -106,12 +107,16 @@ open class StatsServiceRemoteV2: ServiceRemoteWordPressComREST { /// 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, - completion: @escaping ((TimeStatsType?, Error?) -> Void)) { + 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) @@ -124,6 +129,14 @@ open class StatsServiceRemoteV2: ServiceRemoteWordPressComREST { 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] @@ -147,14 +160,15 @@ open class StatsServiceRemoteV2: ServiceRemoteWordPressComREST { 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 { + 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 + } + return } completion(timestats, nil) @@ -397,14 +411,15 @@ extension StatsTimeIntervalData { // 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]? { - guard - let days = jsonDictionary["days"] as? [String: AnyObject], - let firstKey = days.keys.first, - let firstDay = days[firstKey] as? [String: AnyObject] - else { - return nil + 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 firstDay + return nil } } From a8b31bcedbff13ab01064f71929f2febafe613dc Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Tue, 22 Jul 2025 13:04:22 -0400 Subject: [PATCH 15/23] Add initial StatsArchiveTimeIntervalData --- .../StatsArchiveTimeIntervalData.swift | 86 +++++++++++++++++++ .../Mock Data/stats-archives-data.json | 70 +++++++++++++++ .../Tests/StatsRemoteV2Tests.swift | 53 ++++++++++++ WordPressKit.xcodeproj/project.pbxproj | 8 ++ 4 files changed, 217 insertions(+) create mode 100644 Sources/WordPressKit/Models/Stats/Time Interval/StatsArchiveTimeIntervalData.swift create mode 100644 Tests/WordPressKitTests/Mock Data/stats-archives-data.json diff --git a/Sources/WordPressKit/Models/Stats/Time Interval/StatsArchiveTimeIntervalData.swift b/Sources/WordPressKit/Models/Stats/Time Interval/StatsArchiveTimeIntervalData.swift new file mode 100644 index 00000000..991f08df --- /dev/null +++ b/Sources/WordPressKit/Models/Stats/Time Interval/StatsArchiveTimeIntervalData.swift @@ -0,0 +1,86 @@ +import Foundation + +public struct StatsArchiveTimeIntervalData { + public let period: StatsPeriodUnit + public let unit: StatsPeriodUnit? + public let periodEndDate: Date + public let summary: StatsArchiveSummary + + public init(period: StatsPeriodUnit, + unit: StatsPeriodUnit? = nil, + periodEndDate: Date, + summary: StatsArchiveSummary) { + self.period = period + self.unit = unit + self.periodEndDate = periodEndDate + self.summary = summary + } +} + +public struct StatsArchiveSummary { + public let other: [StatsArchiveItem] + public let author: [StatsArchiveItem] + + public init(other: [StatsArchiveItem], author: [StatsArchiveItem]) { + self.other = other + self.author = author + } +} + +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 + } + + let otherItems = (summary["other"] as? [[String: AnyObject]] ?? []).compactMap { StatsArchiveItem(jsonDictionary: $0) } + let authorItems = (summary["author"] as? [[String: AnyObject]] ?? []).compactMap { StatsArchiveItem(jsonDictionary: $0) } + + let archiveSummary = StatsArchiveSummary(other: otherItems, author: authorItems) + + self.period = period + self.unit = unit + self.periodEndDate = date + self.summary = archiveSummary + } +} + +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/Tests/WordPressKitTests/Mock Data/stats-archives-data.json b/Tests/WordPressKitTests/Mock Data/stats-archives-data.json new file mode 100644 index 00000000..46746eba --- /dev/null +++ b/Tests/WordPressKitTests/Mock Data/stats-archives-data.json @@ -0,0 +1,70 @@ +{ + "date": "2025-07-21", + "period": "day", + "summary": { + "other": [ + { + "href": "http://example.com/wp-admin/admin.php?page=stats", + "value": "/wp-admin/admin.php?page=stats", + "views": 10 + }, + { + "href": "http://example.com/wp-admin/", + "value": "/wp-admin/", + "views": 4 + }, + { + "href": "http://example.com/wp-admin/edit.php", + "value": "/wp-admin/edit.php", + "views": 4 + }, + { + "href": "http://example.com/wp-admin/index.php", + "value": "/wp-admin/index.php", + "views": 2 + }, + { + "href": "http://example.com/wp-admin/revision.php?revision=12345", + "value": "/wp-admin/revision.php?revision=12345", + "views": 2 + }, + { + "href": "http://example.com/wp-admin/admin.php?page=settings", + "value": "/wp-admin/admin.php?page=settings", + "views": 1 + }, + { + "href": "http://example.com/wp-admin/post.php?post=67890&action=edit", + "value": "/wp-admin/post.php?post=67890&action=edit", + "views": 1 + }, + { + "href": "http://example.com/wp-admin/profile.php", + "value": "/wp-admin/profile.php", + "views": 1 + } + ], + "author": [ + { + "href": "http://example.com/author/johndoe/", + "value": "johndoe", + "views": 31 + }, + { + "href": "http://example.com/author/janedoe/", + "value": "janedoe", + "views": 5 + }, + { + "href": "http://example.com/author/testuser/", + "value": "testuser", + "views": 2 + }, + { + "href": "http://example.com/author//", + "value": "", + "views": 2 + } + ] + } +} \ No newline at end of file diff --git a/Tests/WordPressKitTests/Tests/StatsRemoteV2Tests.swift b/Tests/WordPressKitTests/Tests/StatsRemoteV2Tests.swift index aee30d7b..57f38e06 100644 --- a/Tests/WordPressKitTests/Tests/StatsRemoteV2Tests.swift +++ b/Tests/WordPressKitTests/Tests/StatsRemoteV2Tests.swift @@ -26,6 +26,7 @@ class StatsRemoteV2Tests: RemoteTestCase, RESTTestable { let getPostsDetailsFilename = "stats-post-details.json" let toggleSpamStateResponseFilename = "stats-referrer-mark-as-spam.json" let getStatsSummaryFilename = "stats-summary.json" + let getArchivesDataFilename = "stats-archives-data.json" // MARK: - Properties @@ -42,6 +43,7 @@ class StatsRemoteV2Tests: RemoteTestCase, RESTTestable { var siteDownloadsDataEndpoint: String { return "sites/\(siteID)/stats/file-downloads/" } var sitePostDetailsEndpoint: String { return "sites/\(siteID)/stats/post/9001" } var siteStatsSummaryEndpoint: String { return "sites/\(siteID)/stats/summary/" } + var siteArchivesDataEndpoint: String { return "sites/\(siteID)/stats/archives" } func toggleSpamStateEndpoint(for referrerDomain: String, markAsSpam: Bool) -> String { let action = markAsSpam ? "new" : "delete" @@ -738,4 +740,55 @@ class StatsRemoteV2Tests: RemoteTestCase, RESTTestable { waitForExpectations(timeout: timeout, handler: nil) } + + func testArchives() { + let expect = expectation(description: "It should return archives data for a day") + + stubRemoteResponse(siteArchivesDataEndpoint, filename: getArchivesDataFilename, contentType: .ApplicationJSON) + + let july21 = DateComponents(year: 2025, month: 7, day: 21) + let date = Calendar.autoupdatingCurrent.date(from: july21)! + + remote.getData(for: .day, endingOn: date) { (archives: StatsArchiveTimeIntervalData?, error: Error?) in + XCTAssertNil(error) + XCTAssertNotNil(archives) + + XCTAssertEqual(archives?.period, .day) + XCTAssertEqual(archives?.periodEndDate, date) + + // Test other items + XCTAssertEqual(archives?.summary.other.count, 8) + + XCTAssertEqual(archives?.summary.other.first?.href, "http://example.com/wp-admin/admin.php?page=stats") + XCTAssertEqual(archives?.summary.other.first?.value, "/wp-admin/admin.php?page=stats") + XCTAssertEqual(archives?.summary.other.first?.views, 10) + + XCTAssertEqual(archives?.summary.other[1].href, "http://example.com/wp-admin/") + XCTAssertEqual(archives?.summary.other[1].value, "/wp-admin/") + XCTAssertEqual(archives?.summary.other[1].views, 4) + + XCTAssertEqual(archives?.summary.other.last?.href, "http://example.com/wp-admin/profile.php") + XCTAssertEqual(archives?.summary.other.last?.value, "/wp-admin/profile.php") + XCTAssertEqual(archives?.summary.other.last?.views, 1) + + // Test author items + XCTAssertEqual(archives?.summary.author.count, 4) + + XCTAssertEqual(archives?.summary.author.first?.href, "http://example.com/author/johndoe/") + XCTAssertEqual(archives?.summary.author.first?.value, "johndoe") + XCTAssertEqual(archives?.summary.author.first?.views, 31) + + XCTAssertEqual(archives?.summary.author[1].href, "http://example.com/author/janedoe/") + XCTAssertEqual(archives?.summary.author[1].value, "janedoe") + XCTAssertEqual(archives?.summary.author[1].views, 5) + + XCTAssertEqual(archives?.summary.author.last?.href, "http://example.com/author//") + XCTAssertEqual(archives?.summary.author.last?.value, "") + XCTAssertEqual(archives?.summary.author.last?.views, 2) + + expect.fulfill() + } + + waitForExpectations(timeout: timeout, handler: nil) + } } diff --git a/WordPressKit.xcodeproj/project.pbxproj b/WordPressKit.xcodeproj/project.pbxproj index 9d51dbf8..72e220d6 100644 --- a/WordPressKit.xcodeproj/project.pbxproj +++ b/WordPressKit.xcodeproj/project.pbxproj @@ -20,6 +20,8 @@ 0C1C08412B9CD79900E52F8C /* PostServiceRemoteExtended.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C1C08402B9CD79900E52F8C /* PostServiceRemoteExtended.swift */; }; 0C1C08432B9CD8D200E52F8C /* PostServiceRemoteREST+Extended.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C1C08422B9CD8D200E52F8C /* PostServiceRemoteREST+Extended.swift */; }; 0C1C08452B9CDB0B00E52F8C /* PostServiceRemoteXMLRPC+Extended.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C1C08442B9CDB0B00E52F8C /* PostServiceRemoteXMLRPC+Extended.swift */; }; + 0C31499B2E2FFBA100AAF9DF /* StatsArchiveTimeIntervalData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C31499A2E2FFBA100AAF9DF /* StatsArchiveTimeIntervalData.swift */; }; + 0C31499D2E2FFBF000AAF9DF /* stats-archives-data.json in Resources */ = {isa = PBXBuildFile; fileRef = 0C31499C2E2FFBF000AAF9DF /* stats-archives-data.json */; }; 0C363D422C41B455004E241D /* OCMock in Frameworks */ = {isa = PBXBuildFile; productRef = 0C363D412C41B455004E241D /* OCMock */; }; 0C363D452C41B468004E241D /* OHHTTPStubs in Frameworks */ = {isa = PBXBuildFile; productRef = 0C363D442C41B468004E241D /* OHHTTPStubs */; }; 0C363D472C41B468004E241D /* OHHTTPStubsSwift in Frameworks */ = {isa = PBXBuildFile; productRef = 0C363D462C41B468004E241D /* OHHTTPStubsSwift */; }; @@ -807,6 +809,8 @@ 0C1C08402B9CD79900E52F8C /* PostServiceRemoteExtended.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostServiceRemoteExtended.swift; sourceTree = ""; }; 0C1C08422B9CD8D200E52F8C /* PostServiceRemoteREST+Extended.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PostServiceRemoteREST+Extended.swift"; sourceTree = ""; }; 0C1C08442B9CDB0B00E52F8C /* PostServiceRemoteXMLRPC+Extended.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PostServiceRemoteXMLRPC+Extended.swift"; sourceTree = ""; }; + 0C31499A2E2FFBA100AAF9DF /* StatsArchiveTimeIntervalData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatsArchiveTimeIntervalData.swift; sourceTree = ""; }; + 0C31499C2E2FFBF000AAF9DF /* stats-archives-data.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "stats-archives-data.json"; sourceTree = ""; }; 0C3A2A412A2E7BA500FD91D6 /* CHANGELOG.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = CHANGELOG.md; sourceTree = ""; }; 0C6183C62C420A3700289E73 /* Package.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Package.swift; sourceTree = ""; }; 0C674E2F2BF3A91300F3B3D4 /* JetpackAIServiceRemote.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JetpackAIServiceRemote.swift; sourceTree = ""; }; @@ -2284,6 +2288,7 @@ 404057D1221C56AB0060250C /* StatsTopCountryTimeIntervalData.swift */, 4081976E221DDE9B00A298E4 /* StatsTopPostsTimeIntervalData.swift */, 404057D9221C9D560060250C /* StatsTopReferrersTimeIntervalData.swift */, + 0C31499A2E2FFBA100AAF9DF /* StatsArchiveTimeIntervalData.swift */, 404057CD221C38130060250C /* StatsTopVideosTimeIntervalData.swift */, ); path = "Time Interval"; @@ -2609,6 +2614,7 @@ 404057CA221B80BC0060250C /* stats-top-authors.json */, 404057CF221C46780060250C /* stats-videos-data.json */, 4081977A221F153A00A298E4 /* stats-visits-day.json */, + 0C31499C2E2FFBF000AAF9DF /* stats-archives-data.json */, 0CAD70312E2C0AAF00EFD4BC /* stats-visits-hourly.json */, 01438D372B6A35FB0097D60A /* stats-summary.json */, 4081977D221F269A00A298E4 /* stats-visits-month.json */, @@ -3079,6 +3085,7 @@ B04D8C082BB7895A002717A2 /* stats-insight-streak.json in Resources */, 74C473B71EF3229B009918F2 /* site-delete-unexpected-json-failure.json in Resources */, 3297E2852564746800287D21 /* jetpack-scan-unavailable.json in Resources */, + 0C31499D2E2FFBF000AAF9DF /* stats-archives-data.json in Resources */, B04D8C072BB7895A002717A2 /* stats-insight-publicize.json in Resources */, AB49D0B325D1B4D80084905B /* post-likes-failure.json in Resources */, 9A2D0B30225E1245009E585F /* jetpack-service-check-site-success-no-jetpack.json in Resources */, @@ -3376,6 +3383,7 @@ 8BB66DB02523C181000B29DA /* ReaderPostServiceRemote+V2.swift in Sources */, 74E229501F1E741B0085F7F2 /* RemotePublicizeConnection.swift in Sources */, 40E7FEB722106A8D0032834E /* StatsCommentsInsight.swift in Sources */, + 0C31499B2E2FFBA100AAF9DF /* StatsArchiveTimeIntervalData.swift in Sources */, 019C5B8B2BD59CE000A69DB0 /* StatsEmailsSummaryData.swift in Sources */, 9856BE962630B5C200C12FEB /* RemoteUser+Likes.swift in Sources */, 3FD634E52BC3A55F00CEDF5E /* WordPressOrgXMLRPCValidator.swift in Sources */, From 32f941002c04f431f4c8de072bdc898401493500 Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Tue, 22 Jul 2025 15:06:12 -0400 Subject: [PATCH 16/23] Extend StatsPostDetails --- Package.swift | 4 +- .../Models/Stats/StatsPostDetails.swift | 144 ++++++++++++++++-- .../Mock Data/stats-post-details.json | 28 +++- .../Tests/StatsRemoteV2Tests.swift | 83 +++++++--- 4 files changed, 226 insertions(+), 33 deletions(-) diff --git a/Package.swift b/Package.swift index e006ad83..7b676fc2 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/21352383/WordPressKit.zip", - checksum: "8054b66ecf39b8c23acea6d8c5c6c9f65fda01fcce4da7acf7db4273cb8e9145" + url: "https://github.com/user-attachments/files/21373882/WordPressKit.zip", + checksum: "57a23a1340f2a9d24f1848b337da89c3556f1440767d91cc2a5ee8a6fe16b79b" ), ] ) diff --git a/Sources/WordPressKit/Models/Stats/StatsPostDetails.swift b/Sources/WordPressKit/Models/Stats/StatsPostDetails.swift index 46d5823d..8b88c9ff 100644 --- a/Sources/WordPressKit/Models/Stats/StatsPostDetails.swift +++ b/Sources/WordPressKit/Models/Stats/StatsPostDetails.swift @@ -1,11 +1,87 @@ +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 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 { @@ -15,6 +91,7 @@ public struct StatsWeeklyBreakdown: Equatable { public let totalViewsCount: Int public let averageViewsCount: Int public let changePercentage: Double + public let isChangeInfinity: Bool public let days: [StatsPostViews] } @@ -26,7 +103,7 @@ public struct StatsPostViews: Equatable { } extension StatsPostDetails { - init?(jsonDictionary: [String: AnyObject]) { + public init?(jsonDictionary: [String: AnyObject]) { guard let fetchedDateString = jsonDictionary["date"] as? String, let date = type(of: self).dateFormatter.date(from: fetchedDateString), @@ -35,8 +112,8 @@ extension StatsPostDetails { let monthlyAverages = jsonDictionary["averages"] as? [String: AnyObject], let recentWeeks = jsonDictionary["weeks"] as? [[String: AnyObject]], let data = jsonDictionary["data"] as? [[Any]] - else { - return nil + else { + return nil } self.fetchedDate = date @@ -50,6 +127,42 @@ extension StatsPostDetails { 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 { @@ -93,19 +206,30 @@ extension StatsPostViews { let totalViews = $0["total"] as? Int, let averageViews = $0["average"] as? Int, let days = $0["days"] as? [[String: AnyObject]] - else { - return nil + else { + return nil } - let change = ($0["change"] as? Double) ?? 0.0 + 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 + else { + return nil } return StatsPostViews(period: .day, @@ -122,9 +246,9 @@ extension StatsPostViews { totalViewsCount: totalViews, averageViewsCount: averageViews, changePercentage: change, + isChangeInfinity: isChangeInfinity, days: mappedDays) } - } } diff --git a/Tests/WordPressKitTests/Mock Data/stats-post-details.json b/Tests/WordPressKitTests/Mock Data/stats-post-details.json index de3d3edb..5394f10b 100644 --- a/Tests/WordPressKitTests/Mock Data/stats-post-details.json +++ b/Tests/WordPressKitTests/Mock Data/stats-post-details.json @@ -5424,5 +5424,31 @@ "highest_month": 8800, "highest_day_average": 283, "highest_week_average": 334, - "post": null + "post": { + "ID": 12345, + "post_author": "1234567", + "post_date": "2019-01-15 12:30:00", + "post_date_gmt": "2019-01-15 12:30:00", + "post_content": "\n

This is a sample blog post content.

\n\n\n\n

Sample Heading

\n\n\n\n

Lorem ipsum dolor sit amet, consectetur adipiscing elit.

\n", + "post_title": "Sample Blog Post Title", + "post_excerpt": "This is a sample excerpt.", + "post_status": "publish", + "comment_status": "open", + "ping_status": "open", + "post_password": "", + "post_name": "sample-blog-post-title", + "to_ping": "", + "pinged": "", + "post_modified": "2019-01-15 14:45:00", + "post_modified_gmt": "2019-01-15 14:45:00", + "post_content_filtered": "", + "post_parent": 0, + "guid": "https://example.wordpress.com/?p=12345", + "menu_order": 0, + "post_type": "post", + "post_mime_type": "", + "comment_count": "3", + "filter": "raw", + "permalink": "http://example.wordpress.com/2019/01/15/sample-blog-post-title/" + } } diff --git a/Tests/WordPressKitTests/Tests/StatsRemoteV2Tests.swift b/Tests/WordPressKitTests/Tests/StatsRemoteV2Tests.swift index 57f38e06..ca82549c 100644 --- a/Tests/WordPressKitTests/Tests/StatsRemoteV2Tests.swift +++ b/Tests/WordPressKitTests/Tests/StatsRemoteV2Tests.swift @@ -475,54 +475,55 @@ class StatsRemoteV2Tests: RemoteTestCase, RESTTestable { func testFetchPostDetail() { let expect = expectation(description: "It should return post detail") - + stubRemoteResponse(sitePostDetailsEndpoint, filename: getPostsDetailsFilename, contentType: .ApplicationJSON) - + let feb21 = DateComponents(year: 2019, month: 2, day: 21) let date = Calendar.autoupdatingCurrent.date(from: feb21)! - + remote.getDetails(forPostID: 9001) { (postDetails, error) in XCTAssertNil(error) XCTAssertNotNil(postDetails) - + XCTAssertEqual(postDetails?.fetchedDate, date) XCTAssertEqual(postDetails?.totalViewsCount, 163343) - + let dailyAverages = 10 + 12 + 12 + 12 + 2 XCTAssertEqual(postDetails?.dailyAveragesPerMonth.count, postDetails?.monthlyBreakdown.count) XCTAssertEqual(postDetails?.dailyAveragesPerMonth.count, dailyAverages) - + let feb19Averages = postDetails?.dailyAveragesPerMonth.first { $0.date == DateComponents(year: 2019, month: 2) } XCTAssertNotNil(feb19Averages) XCTAssertEqual(feb19Averages?.period, .month) XCTAssertEqual(feb19Averages?.viewsCount, 112) - + let feb19Views = postDetails?.monthlyBreakdown.first { $0.date == DateComponents(year: 2019, month: 2) } XCTAssertNotNil(feb19Views) XCTAssertEqual(feb19Views?.period, .month) XCTAssertEqual(feb19Views?.viewsCount, 2578) - + XCTAssertEqual(postDetails?.lastTwoWeeks.count, 14) - + XCTAssertEqual(postDetails?.lastTwoWeeks.first?.viewsCount, 112) XCTAssertEqual(postDetails?.lastTwoWeeks.first?.period, .day) XCTAssertEqual(postDetails?.lastTwoWeeks.first?.date, DateComponents(year: 2019, month: 2, day: 08)) - + XCTAssertEqual(postDetails?.lastTwoWeeks.last?.viewsCount, 324) XCTAssertEqual(postDetails?.lastTwoWeeks.last?.period, .day) XCTAssertEqual(postDetails?.lastTwoWeeks.last?.date, DateComponents(year: 2019, month: 2, day: 21)) - + XCTAssertEqual(postDetails?.recentWeeks.count, 6) - + let leastRecentWeek = postDetails?.recentWeeks.first let mostRecentWeek = postDetails?.recentWeeks.last - + XCTAssertNotNil(leastRecentWeek) XCTAssertNotNil(mostRecentWeek) - + XCTAssertEqual(leastRecentWeek?.totalViewsCount, 688) XCTAssertEqual(leastRecentWeek?.averageViewsCount, 98) XCTAssertEqual(leastRecentWeek!.changePercentage, 0.0, accuracy: 0.0000000001) + XCTAssertFalse(leastRecentWeek!.isChangeInfinity) XCTAssertEqual(leastRecentWeek?.startDay, DateComponents(year: 2019, month: 01, day: 14)) XCTAssertEqual(leastRecentWeek?.endDay, DateComponents(year: 2019, month: 01, day: 20)) XCTAssertEqual(leastRecentWeek?.days.count, 7) @@ -530,10 +531,11 @@ class StatsRemoteV2Tests: RemoteTestCase, RESTTestable { XCTAssertEqual(leastRecentWeek?.days.last?.date, leastRecentWeek?.endDay) XCTAssertEqual(leastRecentWeek?.days.first?.viewsCount, 174) XCTAssertEqual(leastRecentWeek?.days.last?.viewsCount, 60) - + XCTAssertEqual(mostRecentWeek?.totalViewsCount, 867) XCTAssertEqual(mostRecentWeek?.averageViewsCount, 181) XCTAssertEqual(mostRecentWeek!.changePercentage, 38.7732, accuracy: 0.001) + XCTAssertFalse(mostRecentWeek!.isChangeInfinity) XCTAssertEqual(mostRecentWeek?.startDay, DateComponents(year: 2019, month: 02, day: 18)) XCTAssertEqual(mostRecentWeek?.endDay, DateComponents(year: 2019, month: 02, day: 21)) XCTAssertEqual(mostRecentWeek?.days.count, 4) @@ -541,10 +543,49 @@ class StatsRemoteV2Tests: RemoteTestCase, RESTTestable { XCTAssertEqual(mostRecentWeek?.days.last?.date, mostRecentWeek?.endDay) XCTAssertEqual(mostRecentWeek?.days.first?.viewsCount, 157) XCTAssertEqual(mostRecentWeek?.days.last?.viewsCount, 324) - + + // Test newly added fields + XCTAssertEqual(postDetails?.highestMonth, 8800) + XCTAssertEqual(postDetails?.highestDayAverage, 283) + XCTAssertEqual(postDetails?.highestWeekAverage, 334) + + // Test yearly totals + XCTAssertEqual(postDetails?.yearlyTotals[2015], 37861) + XCTAssertEqual(postDetails?.yearlyTotals[2016], 36447) + XCTAssertEqual(postDetails?.yearlyTotals[2017], 37529) + XCTAssertEqual(postDetails?.yearlyTotals[2018], 45429) + XCTAssertEqual(postDetails?.yearlyTotals[2019], 6077) + + // Test overall averages + XCTAssertEqual(postDetails?.overallAverages[2015], 130) + XCTAssertEqual(postDetails?.overallAverages[2016], 99) + XCTAssertEqual(postDetails?.overallAverages[2017], 102) + XCTAssertEqual(postDetails?.overallAverages[2018], 124) + XCTAssertEqual(postDetails?.overallAverages[2019], 112) + + // Test fields array + XCTAssertEqual(postDetails?.fields, ["period", "views"]) + + // Test post object + XCTAssertNotNil(postDetails?.post) + XCTAssertEqual(postDetails?.post?.postID, 12345) + XCTAssertEqual(postDetails?.post?.title, "Sample Blog Post Title") + XCTAssertEqual(postDetails?.post?.authorID, "1234567") + XCTAssertEqual(postDetails?.post?.status, "publish") + XCTAssertEqual(postDetails?.post?.type, "post") + XCTAssertEqual(postDetails?.post?.excerpt, "This is a sample excerpt.") + XCTAssertEqual(postDetails?.post?.name, "sample-blog-post-title") + XCTAssertEqual(postDetails?.post?.commentStatus, "open") + XCTAssertEqual(postDetails?.post?.password, "") + XCTAssertEqual(postDetails?.post?.parent, 0) + XCTAssertEqual(postDetails?.post?.guid, "https://example.wordpress.com/?p=12345") + XCTAssertEqual(postDetails?.post?.mimeType, "") + XCTAssertEqual(postDetails?.post?.commentCount, "3") + XCTAssertEqual(postDetails?.post?.permalink, "http://example.wordpress.com/2019/01/15/sample-blog-post-title/") + expect.fulfill() } - + waitForExpectations(timeout: timeout, handler: nil) } @@ -576,9 +617,11 @@ class StatsRemoteV2Tests: RemoteTestCase, RESTTestable { XCTAssertEqual(summary?.summaryData[9].likesCount, 126) XCTAssertEqual(summary?.summaryData[9].commentsCount, 0) - XCTAssertEqual(summary?.summaryData[9].periodStartDate, Calendar.autoupdatingCurrent.date(byAdding: .day, - value: 7 * 9, // 7 days * nine objects - to: dec17Date)) + XCTAssertEqual(summary?.summaryData[9].periodStartDate, Calendar.autoupdatingCurrent.date( + byAdding: .day, + value: 7 * 9, // 7 days * nine objects + to: dec17Date + )) expect.fulfill() } From 6588ca8e34d87742ecac48ded8a92e304f1b90af Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Tue, 22 Jul 2025 15:18:28 -0400 Subject: [PATCH 17/23] Fix SwiftLint warnings --- .../WordPressKit/Models/Stats/StatsPostDetails.swift | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/Sources/WordPressKit/Models/Stats/StatsPostDetails.swift b/Sources/WordPressKit/Models/Stats/StatsPostDetails.swift index 8b88c9ff..40e03df5 100644 --- a/Sources/WordPressKit/Models/Stats/StatsPostDetails.swift +++ b/Sources/WordPressKit/Models/Stats/StatsPostDetails.swift @@ -39,7 +39,7 @@ public struct StatsPostDetails: Equatable { public let mimeType: String? public let commentCount: String? public let permalink: String? - + init?(jsonDictionary: [String: AnyObject]) { guard let postID = jsonDictionary["ID"] as? Int, @@ -47,21 +47,21 @@ public struct StatsPostDetails: Equatable { 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 From c31ddf14716f5ce5d810a21d1cacb5c2da8709d5 Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Wed, 23 Jul 2025 09:29:22 -0400 Subject: [PATCH 18/23] Add all data to StatsPostDetails --- Package.swift | 4 +-- .../Models/Stats/StatsPostDetails.swift | 25 +++++++++++-------- 2 files changed, 16 insertions(+), 13 deletions(-) diff --git a/Package.swift b/Package.swift index 7b676fc2..c1dfff99 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/21373882/WordPressKit.zip", - checksum: "57a23a1340f2a9d24f1848b337da89c3556f1440767d91cc2a5ee8a6fe16b79b" + url: "https://github.com/user-attachments/files/21388771/WordPressKit.zip", + checksum: "543f8dd4ee1bef8912c640aca0bfbb74db95e4577cc19fa82461edeeac45a02b" ), ] ) diff --git a/Sources/WordPressKit/Models/Stats/StatsPostDetails.swift b/Sources/WordPressKit/Models/Stats/StatsPostDetails.swift index 40e03df5..214ded4f 100644 --- a/Sources/WordPressKit/Models/Stats/StatsPostDetails.swift +++ b/Sources/WordPressKit/Models/Stats/StatsPostDetails.swift @@ -3,23 +3,24 @@ 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 @@ -39,7 +40,7 @@ public struct StatsPostDetails: Equatable { public let mimeType: String? public let commentCount: String? public let permalink: String? - + init?(jsonDictionary: [String: AnyObject]) { guard let postID = jsonDictionary["ID"] as? Int, @@ -47,21 +48,21 @@ public struct StatsPostDetails: Equatable { 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 @@ -119,6 +120,8 @@ extension StatsPostDetails { 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 From 2613f213b1a958266f88fa7e99d990292096bffb Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Thu, 24 Jul 2025 19:42:01 -0400 Subject: [PATCH 19/23] Generalized Archive endpoint parser --- Package.swift | 4 +- .../StatsArchiveTimeIntervalData.swift | 32 ++--- .../Mock Data/stats-visits-hourly.json | 5 +- .../Tests/StatsRemoteV2Tests.swift | 132 +++++++++--------- 4 files changed, 84 insertions(+), 89 deletions(-) diff --git a/Package.swift b/Package.swift index c1dfff99..42706d96 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/21388771/WordPressKit.zip", - checksum: "543f8dd4ee1bef8912c640aca0bfbb74db95e4577cc19fa82461edeeac45a02b" + url: "https://github.com/user-attachments/files/21420518/WordPressKit.zip", + checksum: "c57f60d8476cb1ba7000a2aa1fe0607794e1660c964d31ddab6e5add5db70499" ), ] ) diff --git a/Sources/WordPressKit/Models/Stats/Time Interval/StatsArchiveTimeIntervalData.swift b/Sources/WordPressKit/Models/Stats/Time Interval/StatsArchiveTimeIntervalData.swift index 991f08df..b990c7fc 100644 --- a/Sources/WordPressKit/Models/Stats/Time Interval/StatsArchiveTimeIntervalData.swift +++ b/Sources/WordPressKit/Models/Stats/Time Interval/StatsArchiveTimeIntervalData.swift @@ -4,12 +4,12 @@ public struct StatsArchiveTimeIntervalData { public let period: StatsPeriodUnit public let unit: StatsPeriodUnit? public let periodEndDate: Date - public let summary: StatsArchiveSummary + public let summary: [String: [StatsArchiveItem]] public init(period: StatsPeriodUnit, unit: StatsPeriodUnit? = nil, periodEndDate: Date, - summary: StatsArchiveSummary) { + summary: [String: [StatsArchiveItem]]) { self.period = period self.unit = unit self.periodEndDate = periodEndDate @@ -17,16 +17,6 @@ public struct StatsArchiveTimeIntervalData { } } -public struct StatsArchiveSummary { - public let other: [StatsArchiveItem] - public let author: [StatsArchiveItem] - - public init(other: [StatsArchiveItem], author: [StatsArchiveItem]) { - self.other = other - self.author = author - } -} - public struct StatsArchiveItem { public let href: String public let value: String @@ -57,15 +47,21 @@ extension StatsArchiveTimeIntervalData: StatsTimeIntervalData { return nil } - let otherItems = (summary["other"] as? [[String: AnyObject]] ?? []).compactMap { StatsArchiveItem(jsonDictionary: $0) } - let authorItems = (summary["author"] as? [[String: AnyObject]] ?? []).compactMap { StatsArchiveItem(jsonDictionary: $0) } - - let archiveSummary = StatsArchiveSummary(other: otherItems, author: authorItems) - self.period = period self.unit = unit self.periodEndDate = date - self.summary = archiveSummary + 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 + }() } } diff --git a/Tests/WordPressKitTests/Mock Data/stats-visits-hourly.json b/Tests/WordPressKitTests/Mock Data/stats-visits-hourly.json index af72f393..ef412c43 100644 --- a/Tests/WordPressKitTests/Mock Data/stats-visits-hourly.json +++ b/Tests/WordPressKitTests/Mock Data/stats-visits-hourly.json @@ -11,7 +11,7 @@ "data": [ [ "2025-07-17 00:00:00", - 0, + 5140, null, null, null @@ -74,7 +74,8 @@ ], [ "2025-07-17 09:00:00", - 0, + 3244 + , null, null, null diff --git a/Tests/WordPressKitTests/Tests/StatsRemoteV2Tests.swift b/Tests/WordPressKitTests/Tests/StatsRemoteV2Tests.swift index ca82549c..91806289 100644 --- a/Tests/WordPressKitTests/Tests/StatsRemoteV2Tests.swift +++ b/Tests/WordPressKitTests/Tests/StatsRemoteV2Tests.swift @@ -447,25 +447,20 @@ class StatsRemoteV2Tests: RemoteTestCase, RESTTestable { let feb21 = DateComponents(year: 2019, month: 2, day: 21) let date = Calendar.autoupdatingCurrent.date(from: feb21)! - remote.getData(for: .day, endingOn: date) { (summary: StatsSummaryTimeIntervalData?, error: Error?) in + remote.getData(for: .hour, endingOn: date) { (summary: StatsSiteMetricsResponse?, error: Error?) in XCTAssertNil(error) XCTAssertNotNil(summary) - XCTAssertEqual(summary?.summaryData.count, 10) + XCTAssertEqual(summary?.data.count, 24) - XCTAssertEqual(summary?.summaryData[0].viewsCount, 5140) - XCTAssertEqual(summary?.summaryData[0].visitorsCount, 3560) - XCTAssertEqual(summary?.summaryData[0].likesCount, 70) - XCTAssertEqual(summary?.summaryData[0].commentsCount, 1) + XCTAssertEqual(summary?.data[0].views, 5140) + XCTAssertNil(summary?.data[0].visitors) + XCTAssertNil(summary?.data[0].likes) + XCTAssertNil(summary?.data[0].comments) - let nineDaysAgo = Calendar.autoupdatingCurrent.date(byAdding: .day, value: -9, to: date)! - XCTAssertEqual(summary?.summaryData[0].periodStartDate, nineDaysAgo) - - XCTAssertEqual(summary?.summaryData[9].viewsCount, 3244) - XCTAssertEqual(summary?.summaryData[9].visitorsCount, 2127) - XCTAssertEqual(summary?.summaryData[9].likesCount, 25) - XCTAssertEqual(summary?.summaryData[9].commentsCount, 0) - XCTAssertEqual(summary?.summaryData[9].periodStartDate, date) + XCTAssertEqual(summary?.data[9].views, 3244) + XCTAssertNil(summary?.data[9].likes) + XCTAssertNil(summary?.data[9].comments) expect.fulfill() } @@ -475,51 +470,51 @@ class StatsRemoteV2Tests: RemoteTestCase, RESTTestable { func testFetchPostDetail() { let expect = expectation(description: "It should return post detail") - + stubRemoteResponse(sitePostDetailsEndpoint, filename: getPostsDetailsFilename, contentType: .ApplicationJSON) - + let feb21 = DateComponents(year: 2019, month: 2, day: 21) let date = Calendar.autoupdatingCurrent.date(from: feb21)! - + remote.getDetails(forPostID: 9001) { (postDetails, error) in XCTAssertNil(error) XCTAssertNotNil(postDetails) - + XCTAssertEqual(postDetails?.fetchedDate, date) XCTAssertEqual(postDetails?.totalViewsCount, 163343) - + let dailyAverages = 10 + 12 + 12 + 12 + 2 XCTAssertEqual(postDetails?.dailyAveragesPerMonth.count, postDetails?.monthlyBreakdown.count) XCTAssertEqual(postDetails?.dailyAveragesPerMonth.count, dailyAverages) - + let feb19Averages = postDetails?.dailyAveragesPerMonth.first { $0.date == DateComponents(year: 2019, month: 2) } XCTAssertNotNil(feb19Averages) XCTAssertEqual(feb19Averages?.period, .month) XCTAssertEqual(feb19Averages?.viewsCount, 112) - + let feb19Views = postDetails?.monthlyBreakdown.first { $0.date == DateComponents(year: 2019, month: 2) } XCTAssertNotNil(feb19Views) XCTAssertEqual(feb19Views?.period, .month) XCTAssertEqual(feb19Views?.viewsCount, 2578) - + XCTAssertEqual(postDetails?.lastTwoWeeks.count, 14) - + XCTAssertEqual(postDetails?.lastTwoWeeks.first?.viewsCount, 112) XCTAssertEqual(postDetails?.lastTwoWeeks.first?.period, .day) XCTAssertEqual(postDetails?.lastTwoWeeks.first?.date, DateComponents(year: 2019, month: 2, day: 08)) - + XCTAssertEqual(postDetails?.lastTwoWeeks.last?.viewsCount, 324) XCTAssertEqual(postDetails?.lastTwoWeeks.last?.period, .day) XCTAssertEqual(postDetails?.lastTwoWeeks.last?.date, DateComponents(year: 2019, month: 2, day: 21)) - + XCTAssertEqual(postDetails?.recentWeeks.count, 6) - + let leastRecentWeek = postDetails?.recentWeeks.first let mostRecentWeek = postDetails?.recentWeeks.last - + XCTAssertNotNil(leastRecentWeek) XCTAssertNotNil(mostRecentWeek) - + XCTAssertEqual(leastRecentWeek?.totalViewsCount, 688) XCTAssertEqual(leastRecentWeek?.averageViewsCount, 98) XCTAssertEqual(leastRecentWeek!.changePercentage, 0.0, accuracy: 0.0000000001) @@ -531,7 +526,7 @@ class StatsRemoteV2Tests: RemoteTestCase, RESTTestable { XCTAssertEqual(leastRecentWeek?.days.last?.date, leastRecentWeek?.endDay) XCTAssertEqual(leastRecentWeek?.days.first?.viewsCount, 174) XCTAssertEqual(leastRecentWeek?.days.last?.viewsCount, 60) - + XCTAssertEqual(mostRecentWeek?.totalViewsCount, 867) XCTAssertEqual(mostRecentWeek?.averageViewsCount, 181) XCTAssertEqual(mostRecentWeek!.changePercentage, 38.7732, accuracy: 0.001) @@ -543,29 +538,29 @@ class StatsRemoteV2Tests: RemoteTestCase, RESTTestable { XCTAssertEqual(mostRecentWeek?.days.last?.date, mostRecentWeek?.endDay) XCTAssertEqual(mostRecentWeek?.days.first?.viewsCount, 157) XCTAssertEqual(mostRecentWeek?.days.last?.viewsCount, 324) - + // Test newly added fields XCTAssertEqual(postDetails?.highestMonth, 8800) XCTAssertEqual(postDetails?.highestDayAverage, 283) XCTAssertEqual(postDetails?.highestWeekAverage, 334) - + // Test yearly totals XCTAssertEqual(postDetails?.yearlyTotals[2015], 37861) XCTAssertEqual(postDetails?.yearlyTotals[2016], 36447) XCTAssertEqual(postDetails?.yearlyTotals[2017], 37529) XCTAssertEqual(postDetails?.yearlyTotals[2018], 45429) XCTAssertEqual(postDetails?.yearlyTotals[2019], 6077) - + // Test overall averages XCTAssertEqual(postDetails?.overallAverages[2015], 130) XCTAssertEqual(postDetails?.overallAverages[2016], 99) XCTAssertEqual(postDetails?.overallAverages[2017], 102) XCTAssertEqual(postDetails?.overallAverages[2018], 124) XCTAssertEqual(postDetails?.overallAverages[2019], 112) - + // Test fields array XCTAssertEqual(postDetails?.fields, ["period", "views"]) - + // Test post object XCTAssertNotNil(postDetails?.post) XCTAssertEqual(postDetails?.post?.postID, 12345) @@ -582,10 +577,10 @@ class StatsRemoteV2Tests: RemoteTestCase, RESTTestable { XCTAssertEqual(postDetails?.post?.mimeType, "") XCTAssertEqual(postDetails?.post?.commentCount, "3") XCTAssertEqual(postDetails?.post?.permalink, "http://example.wordpress.com/2019/01/15/sample-blog-post-title/") - + expect.fulfill() } - + waitForExpectations(timeout: timeout, handler: nil) } @@ -784,7 +779,7 @@ class StatsRemoteV2Tests: RemoteTestCase, RESTTestable { } - func testArchives() { + func testArchives() throws { let expect = expectation(description: "It should return archives data for a day") stubRemoteResponse(siteArchivesDataEndpoint, filename: getArchivesDataFilename, contentType: .ApplicationJSON) @@ -792,46 +787,49 @@ class StatsRemoteV2Tests: RemoteTestCase, RESTTestable { let july21 = DateComponents(year: 2025, month: 7, day: 21) let date = Calendar.autoupdatingCurrent.date(from: july21)! - remote.getData(for: .day, endingOn: date) { (archives: StatsArchiveTimeIntervalData?, error: Error?) in + var returnValue: StatsArchiveTimeIntervalData? + remote.getData(for: .day, endingOn: date) { (value: StatsArchiveTimeIntervalData?, error: Error?) in XCTAssertNil(error) - XCTAssertNotNil(archives) - - XCTAssertEqual(archives?.period, .day) - XCTAssertEqual(archives?.periodEndDate, date) + returnValue = value + expect.fulfill() + } + waitForExpectations(timeout: timeout, handler: nil) - // Test other items - XCTAssertEqual(archives?.summary.other.count, 8) + let archives = try XCTUnwrap(returnValue) - XCTAssertEqual(archives?.summary.other.first?.href, "http://example.com/wp-admin/admin.php?page=stats") - XCTAssertEqual(archives?.summary.other.first?.value, "/wp-admin/admin.php?page=stats") - XCTAssertEqual(archives?.summary.other.first?.views, 10) + XCTAssertEqual(archives.period, .day) + XCTAssertEqual(archives.periodEndDate, date) - XCTAssertEqual(archives?.summary.other[1].href, "http://example.com/wp-admin/") - XCTAssertEqual(archives?.summary.other[1].value, "/wp-admin/") - XCTAssertEqual(archives?.summary.other[1].views, 4) + // Test other items + let other = try XCTUnwrap(archives.summary["other"]) + XCTAssertEqual(other.count, 8) - XCTAssertEqual(archives?.summary.other.last?.href, "http://example.com/wp-admin/profile.php") - XCTAssertEqual(archives?.summary.other.last?.value, "/wp-admin/profile.php") - XCTAssertEqual(archives?.summary.other.last?.views, 1) + XCTAssertEqual(other.first?.href, "http://example.com/wp-admin/admin.php?page=stats") + XCTAssertEqual(other.first?.value, "/wp-admin/admin.php?page=stats") + XCTAssertEqual(other.first?.views, 10) - // Test author items - XCTAssertEqual(archives?.summary.author.count, 4) + XCTAssertEqual(other[1].href, "http://example.com/wp-admin/") + XCTAssertEqual(other[1].value, "/wp-admin/") + XCTAssertEqual(other[1].views, 4) - XCTAssertEqual(archives?.summary.author.first?.href, "http://example.com/author/johndoe/") - XCTAssertEqual(archives?.summary.author.first?.value, "johndoe") - XCTAssertEqual(archives?.summary.author.first?.views, 31) + XCTAssertEqual(other.last?.href, "http://example.com/wp-admin/profile.php") + XCTAssertEqual(other.last?.value, "/wp-admin/profile.php") + XCTAssertEqual(other.last?.views, 1) - XCTAssertEqual(archives?.summary.author[1].href, "http://example.com/author/janedoe/") - XCTAssertEqual(archives?.summary.author[1].value, "janedoe") - XCTAssertEqual(archives?.summary.author[1].views, 5) + // Test author items + let author = try XCTUnwrap(archives.summary["author"]) + XCTAssertEqual(author.count, 4) - XCTAssertEqual(archives?.summary.author.last?.href, "http://example.com/author//") - XCTAssertEqual(archives?.summary.author.last?.value, "") - XCTAssertEqual(archives?.summary.author.last?.views, 2) + XCTAssertEqual(author.first?.href, "http://example.com/author/johndoe/") + XCTAssertEqual(author.first?.value, "johndoe") + XCTAssertEqual(author.first?.views, 31) - expect.fulfill() - } + XCTAssertEqual(author[1].href, "http://example.com/author/janedoe/") + XCTAssertEqual(author[1].value, "janedoe") + XCTAssertEqual(author[1].views, 5) - waitForExpectations(timeout: timeout, handler: nil) + XCTAssertEqual(author.last?.href, "http://example.com/author//") + XCTAssertEqual(author.last?.value, "") + XCTAssertEqual(author.last?.views, 2) } } From 67db92e3ea5ad8574911df592a838b4729ab9ca5 Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Mon, 28 Jul 2025 14:06:33 -0400 Subject: [PATCH 20/23] Fix StatsPeriodUnit crash --- Package.swift | 4 ++-- .../Stats/Time Interval/StatsSummaryTimeIntervalData.swift | 4 +++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/Package.swift b/Package.swift index 42706d96..734e07c1 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/21420518/WordPressKit.zip", - checksum: "c57f60d8476cb1ba7000a2aa1fe0607794e1660c964d31ddab6e5add5db70499" + url: "https://github.com/user-attachments/files/21474850/WordPressKit.zip", + checksum: "4621e8faa2ce9c7ef847008b044ac9e04e733e6d8ede9e4a424eeb8c832b85c3" ), ] ) diff --git a/Sources/WordPressKit/Models/Stats/Time Interval/StatsSummaryTimeIntervalData.swift b/Sources/WordPressKit/Models/Stats/Time Interval/StatsSummaryTimeIntervalData.swift index 758e4502..d8b158c3 100644 --- a/Sources/WordPressKit/Models/Stats/Time Interval/StatsSummaryTimeIntervalData.swift +++ b/Sources/WordPressKit/Models/Stats/Time Interval/StatsSummaryTimeIntervalData.swift @@ -1,9 +1,11 @@ +import Foundation + @frozen public enum StatsPeriodUnit: Int { - case hour case day case week case month case year + case hour } @frozen public enum StatsSummaryType: Int { From c78d1f2a182d1ca48cb08752f77cc56719621b5a Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Tue, 29 Jul 2025 08:31:19 -0400 Subject: [PATCH 21/23] Add endpoint to fetch stats emails --- Package.swift | 4 +- .../Stats/Emails/StatsEmailOpensData.swift | 42 +++++++++++++++++++ .../Services/StatsServiceRemoteV2.swift | 22 ++++++++++ .../Mock Data/stats-email-opens.json | 6 +++ .../Tests/StatsRemoteV2Tests.swift | 22 ++++++++++ WordPressKit.xcodeproj/project.pbxproj | 8 ++++ 6 files changed, 102 insertions(+), 2 deletions(-) create mode 100644 Sources/WordPressKit/Models/Stats/Emails/StatsEmailOpensData.swift create mode 100644 Tests/WordPressKitTests/Mock Data/stats-email-opens.json diff --git a/Package.swift b/Package.swift index 734e07c1..7f19cd20 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/21474850/WordPressKit.zip", - checksum: "4621e8faa2ce9c7ef847008b044ac9e04e733e6d8ede9e4a424eeb8c832b85c3" + url: "https://github.com/user-attachments/files/21488685/WordPressKit.zip", + checksum: "c591b9d12fdfeeedf7f31d884c6ce751722a37ae8ebb396a511fdb045698bccd" ), ] ) diff --git a/Sources/WordPressKit/Models/Stats/Emails/StatsEmailOpensData.swift b/Sources/WordPressKit/Models/Stats/Emails/StatsEmailOpensData.swift new file mode 100644 index 00000000..2c0378ec --- /dev/null +++ b/Sources/WordPressKit/Models/Stats/Emails/StatsEmailOpensData.swift @@ -0,0 +1,42 @@ +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" + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + totalSends = try container.decodeIfPresent(Int.self, forKey: .totalSends) + uniqueOpens = try container.decodeIfPresent(Int.self, forKey: .uniqueOpens) + totalOpens = try container.decodeIfPresent(Int.self, forKey: .totalOpens) + opensRate = try container.decodeIfPresent(Double.self, forKey: .opensRate) + } +} + +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/Sources/WordPressKit/Services/StatsServiceRemoteV2.swift b/Sources/WordPressKit/Services/StatsServiceRemoteV2.swift index a816cad5..050f918b 100644 --- a/Sources/WordPressKit/Services/StatsServiceRemoteV2.swift +++ b/Sources/WordPressKit/Services/StatsServiceRemoteV2.swift @@ -372,6 +372,28 @@ public extension StatsServiceRemoteV2 { } } +// 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 { diff --git a/Tests/WordPressKitTests/Mock Data/stats-email-opens.json b/Tests/WordPressKitTests/Mock Data/stats-email-opens.json new file mode 100644 index 00000000..aa1e22f0 --- /dev/null +++ b/Tests/WordPressKitTests/Mock Data/stats-email-opens.json @@ -0,0 +1,6 @@ +{ + "total_sends": 1, + "unique_opens": 1, + "total_opens": 4, + "opens_rate": 1 +} \ No newline at end of file diff --git a/Tests/WordPressKitTests/Tests/StatsRemoteV2Tests.swift b/Tests/WordPressKitTests/Tests/StatsRemoteV2Tests.swift index 91806289..73848c01 100644 --- a/Tests/WordPressKitTests/Tests/StatsRemoteV2Tests.swift +++ b/Tests/WordPressKitTests/Tests/StatsRemoteV2Tests.swift @@ -27,6 +27,7 @@ class StatsRemoteV2Tests: RemoteTestCase, RESTTestable { let toggleSpamStateResponseFilename = "stats-referrer-mark-as-spam.json" let getStatsSummaryFilename = "stats-summary.json" let getArchivesDataFilename = "stats-archives-data.json" + let getEmailOpensFilename = "stats-email-opens.json" // MARK: - Properties @@ -44,6 +45,7 @@ class StatsRemoteV2Tests: RemoteTestCase, RESTTestable { var sitePostDetailsEndpoint: String { return "sites/\(siteID)/stats/post/9001" } var siteStatsSummaryEndpoint: String { return "sites/\(siteID)/stats/summary/" } var siteArchivesDataEndpoint: String { return "sites/\(siteID)/stats/archives" } + var siteEmailOpensEndpoint: String { return "sites/\(siteID)/stats/opens/emails/231/rate" } func toggleSpamStateEndpoint(for referrerDomain: String, markAsSpam: Bool) -> String { let action = markAsSpam ? "new" : "delete" @@ -832,4 +834,24 @@ class StatsRemoteV2Tests: RemoteTestCase, RESTTestable { XCTAssertEqual(author.last?.value, "") XCTAssertEqual(author.last?.views, 2) } + + func testEmailOpens() { + let expect = expectation(description: "It should return email opens data") + + stubRemoteResponse(siteEmailOpensEndpoint, filename: getEmailOpensFilename, contentType: .ApplicationJSON) + + remote.getEmailOpens(for: 231) { (emailOpens, error) in + XCTAssertNil(error) + XCTAssertNotNil(emailOpens) + + XCTAssertEqual(emailOpens?.totalSends, 1) + XCTAssertEqual(emailOpens?.uniqueOpens, 1) + XCTAssertEqual(emailOpens?.totalOpens, 4) + XCTAssertEqual(emailOpens?.opensRate, 1) + + expect.fulfill() + } + + waitForExpectations(timeout: timeout, handler: nil) + } } diff --git a/WordPressKit.xcodeproj/project.pbxproj b/WordPressKit.xcodeproj/project.pbxproj index 72e220d6..e68f8d5b 100644 --- a/WordPressKit.xcodeproj/project.pbxproj +++ b/WordPressKit.xcodeproj/project.pbxproj @@ -9,11 +9,13 @@ /* Begin PBXBuildFile section */ 01383F7F2BD5545B00496B76 /* StatsEmailsSummaryDataTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01383F7E2BD5545B00496B76 /* StatsEmailsSummaryDataTests.swift */; }; 01383F822BD556B100496B76 /* stats-emails-summary.json in Resources */ = {isa = PBXBuildFile; fileRef = 01383F802BD5549E00496B76 /* stats-emails-summary.json */; }; + 7B859118F037432BB78618D6 /* stats-email-opens.json in Resources */ = {isa = PBXBuildFile; fileRef = 7B859117F037432BB78618D6 /* stats-email-opens.json */; }; 01438D362B6A31540097D60A /* stats-visits-month-unit-week.json in Resources */ = {isa = PBXBuildFile; fileRef = 01438D342B6A2B2C0097D60A /* stats-visits-month-unit-week.json */; }; 01438D392B6A361B0097D60A /* stats-summary.json in Resources */ = {isa = PBXBuildFile; fileRef = 01438D372B6A35FB0097D60A /* stats-summary.json */; }; 01438D3B2B6A36BF0097D60A /* StatsTotalsSummaryData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01438D3A2B6A36BF0097D60A /* StatsTotalsSummaryData.swift */; }; 0152100C28EDA9E400DD6783 /* StatsAnnualAndMostPopularTimeInsightDecodingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0152100B28EDA9E400DD6783 /* StatsAnnualAndMostPopularTimeInsightDecodingTests.swift */; }; 019C5B8B2BD59CE000A69DB0 /* StatsEmailsSummaryData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 019C5B892BD59CE000A69DB0 /* StatsEmailsSummaryData.swift */; }; + 11B3582786AD49E591C8615E /* StatsEmailOpensData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11B3582686AD49E591C8615E /* StatsEmailOpensData.swift */; }; 0847B92C2A4442730044D32F /* IPLocationRemote.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0847B92B2A4442730044D32F /* IPLocationRemote.swift */; }; 08C7493E2A45EA11000DA0E2 /* IPLocationRemoteTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08C7493D2A45EA11000DA0E2 /* IPLocationRemoteTests.swift */; }; 0C0791B22BFE7DE50049C06E /* JetpackAssistantFeatureDetails.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C0791B12BFE7DE50049C06E /* JetpackAssistantFeatureDetails.swift */; }; @@ -798,11 +800,13 @@ /* Begin PBXFileReference section */ 01383F7E2BD5545B00496B76 /* StatsEmailsSummaryDataTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatsEmailsSummaryDataTests.swift; sourceTree = ""; }; 01383F802BD5549E00496B76 /* stats-emails-summary.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "stats-emails-summary.json"; sourceTree = ""; }; + 7B859117F037432BB78618D6 /* stats-email-opens.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "stats-email-opens.json"; sourceTree = ""; }; 01438D342B6A2B2C0097D60A /* stats-visits-month-unit-week.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "stats-visits-month-unit-week.json"; sourceTree = ""; }; 01438D372B6A35FB0097D60A /* stats-summary.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "stats-summary.json"; sourceTree = ""; }; 01438D3A2B6A36BF0097D60A /* StatsTotalsSummaryData.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StatsTotalsSummaryData.swift; sourceTree = ""; }; 0152100B28EDA9E400DD6783 /* StatsAnnualAndMostPopularTimeInsightDecodingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatsAnnualAndMostPopularTimeInsightDecodingTests.swift; sourceTree = ""; }; 019C5B892BD59CE000A69DB0 /* StatsEmailsSummaryData.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StatsEmailsSummaryData.swift; sourceTree = ""; }; + 11B3582686AD49E591C8615E /* StatsEmailOpensData.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StatsEmailOpensData.swift; sourceTree = ""; }; 0847B92B2A4442730044D32F /* IPLocationRemote.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IPLocationRemote.swift; sourceTree = ""; }; 08C7493D2A45EA11000DA0E2 /* IPLocationRemoteTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IPLocationRemoteTests.swift; sourceTree = ""; }; 0C0791B12BFE7DE50049C06E /* JetpackAssistantFeatureDetails.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JetpackAssistantFeatureDetails.swift; sourceTree = ""; }; @@ -1617,6 +1621,7 @@ isa = PBXGroup; children = ( 019C5B892BD59CE000A69DB0 /* StatsEmailsSummaryData.swift */, + 11B3582686AD49E591C8615E /* StatsEmailOpensData.swift */, ); path = Emails; sourceTree = ""; @@ -2621,6 +2626,7 @@ 01438D342B6A2B2C0097D60A /* stats-visits-month-unit-week.json */, 40819779221F153A00A298E4 /* stats-visits-week.json */, 01383F802BD5549E00496B76 /* stats-emails-summary.json */, + 7B859117F037432BB78618D6 /* stats-email-opens.json */, 436D56392118DE3B00CEAA33 /* supported-countries-success.json */, 436D56522121F60400CEAA33 /* supported-states-empty.json */, 436D563D2118E34D00CEAA33 /* supported-states-success.json */, @@ -3297,6 +3303,7 @@ 7434E1DE1F17C3C900C40DDB /* site-users-update-role-unknown-user-failure.json in Resources */, 4081977B221F153B00A298E4 /* stats-visits-week.json in Resources */, 01383F822BD556B100496B76 /* stats-emails-summary.json in Resources */, + 7B859118F037432BB78618D6 /* stats-email-opens.json in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -3385,6 +3392,7 @@ 40E7FEB722106A8D0032834E /* StatsCommentsInsight.swift in Sources */, 0C31499B2E2FFBA100AAF9DF /* StatsArchiveTimeIntervalData.swift in Sources */, 019C5B8B2BD59CE000A69DB0 /* StatsEmailsSummaryData.swift in Sources */, + 11B3582786AD49E591C8615E /* StatsEmailOpensData.swift in Sources */, 9856BE962630B5C200C12FEB /* RemoteUser+Likes.swift in Sources */, 3FD634E52BC3A55F00CEDF5E /* WordPressOrgXMLRPCValidator.swift in Sources */, 3FE2E97B2BC3A332002CA2E1 /* WordPressAPIError+NSErrorBridge.swift in Sources */, From 1b4d2841631835057adc06fb858d7a3b85e82ef2 Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Fri, 1 Aug 2025 17:54:26 -0400 Subject: [PATCH 22/23] Remove unused StatsEmailOpensData --- .../Models/Stats/Emails/StatsEmailOpensData.swift | 8 -------- Tests/WordPressKitTests/Tests/StatsRemoteV2Tests.swift | 2 +- 2 files changed, 1 insertion(+), 9 deletions(-) diff --git a/Sources/WordPressKit/Models/Stats/Emails/StatsEmailOpensData.swift b/Sources/WordPressKit/Models/Stats/Emails/StatsEmailOpensData.swift index 2c0378ec..9fe52167 100644 --- a/Sources/WordPressKit/Models/Stats/Emails/StatsEmailOpensData.swift +++ b/Sources/WordPressKit/Models/Stats/Emails/StatsEmailOpensData.swift @@ -19,14 +19,6 @@ public struct StatsEmailOpensData: Decodable, Equatable { case totalOpens = "total_opens" case opensRate = "opens_rate" } - - public init(from decoder: any Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - totalSends = try container.decodeIfPresent(Int.self, forKey: .totalSends) - uniqueOpens = try container.decodeIfPresent(Int.self, forKey: .uniqueOpens) - totalOpens = try container.decodeIfPresent(Int.self, forKey: .totalOpens) - opensRate = try container.decodeIfPresent(Double.self, forKey: .opensRate) - } } extension StatsEmailOpensData { diff --git a/Tests/WordPressKitTests/Tests/StatsRemoteV2Tests.swift b/Tests/WordPressKitTests/Tests/StatsRemoteV2Tests.swift index 73848c01..fb5a1d30 100644 --- a/Tests/WordPressKitTests/Tests/StatsRemoteV2Tests.swift +++ b/Tests/WordPressKitTests/Tests/StatsRemoteV2Tests.swift @@ -713,7 +713,7 @@ class StatsRemoteV2Tests: RemoteTestCase, RESTTestable { if let data = stats?.data, data.count == 24 { - XCTAssertEqual(data[0].views, 0) + XCTAssertEqual(data[0].views, 5140) XCTAssertNil(data[0].comments) XCTAssertEqual(data[1].views, 2) XCTAssertNil(data[1].comments) From 1e06e950deafdd65b74489de9bfde2bf08d9163c Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Fri, 1 Aug 2025 18:16:26 -0400 Subject: [PATCH 23/23] Update --- .../Models/Stats/StatsSubscribersSummaryData.swift | 2 +- .../Models/Stats/Time Interval/StatsSiteMetricsResponse.swift | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Sources/WordPressKit/Models/Stats/StatsSubscribersSummaryData.swift b/Sources/WordPressKit/Models/Stats/StatsSubscribersSummaryData.swift index 774f85ad..db89bfc0 100644 --- a/Sources/WordPressKit/Models/Stats/StatsSubscribersSummaryData.swift +++ b/Sources/WordPressKit/Models/Stats/StatsSubscribersSummaryData.swift @@ -19,7 +19,7 @@ extension StatsSubscribersSummaryData: StatsTimeIntervalData { static var hourlyDateFormatter: DateFormatter { let df = DateFormatter() - df.locale = Locale(identifier: "en_US_POS") + df.locale = Locale(identifier: "en_US_POSIX") df.dateFormat = "yyyy-MM-dd HH:mm:ss" return df } diff --git a/Sources/WordPressKit/Models/Stats/Time Interval/StatsSiteMetricsResponse.swift b/Sources/WordPressKit/Models/Stats/Time Interval/StatsSiteMetricsResponse.swift index c991115a..7a9f9da2 100644 --- a/Sources/WordPressKit/Models/Stats/Time Interval/StatsSiteMetricsResponse.swift +++ b/Sources/WordPressKit/Models/Stats/Time Interval/StatsSiteMetricsResponse.swift @@ -75,7 +75,7 @@ extension StatsSiteMetricsResponse: StatsTimeIntervalData { let dateFormatter = makeDateFormatter(for: period) self.data = data.compactMap { data in - guard let periodDate = dateFormatter.date(from: data[periodIndex] as? String ?? "") else { + guard let date = dateFormatter.date(from: data[periodIndex] as? String ?? "") else { return nil } func getValue(at index: Int?) -> Int? { @@ -83,7 +83,7 @@ extension StatsSiteMetricsResponse: StatsTimeIntervalData { return data[index] as? Int } return PeriodData( - date: periodDate, + date: date, views: getValue(at: indices.views), visitors: getValue(at: indices.visitors), likes: getValue(at: indices.likes),