diff --git a/Package.swift b/Package.swift index 9cdb1156..7eb2b940 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/21518814/WordPressKit.zip", - checksum: "a43e82909d851e78dff7fa64edba12635e2e96206c1fcdca075eae02dd4c157e" + url: "https://github.com/user-attachments/files/21582269/WordPressKit.zip", + checksum: "cbfe79d7a4244302d308027ff329f1ccdfd1c604d990871359764eca567ea86f" ), ] ) diff --git a/Sources/WordPressKit/Models/Stats/Emails/StatsEmailOpensData.swift b/Sources/WordPressKit/Models/Stats/Emails/StatsEmailOpensData.swift new file mode 100644 index 00000000..9fe52167 --- /dev/null +++ b/Sources/WordPressKit/Models/Stats/Emails/StatsEmailOpensData.swift @@ -0,0 +1,34 @@ +import Foundation + +public struct StatsEmailOpensData: Decodable, Equatable { + public let totalSends: Int? + public let uniqueOpens: Int? + public let totalOpens: Int? + public let opensRate: Double? + + public init(totalSends: Int?, uniqueOpens: Int?, totalOpens: Int?, opensRate: Double?) { + self.totalSends = totalSends + self.uniqueOpens = uniqueOpens + self.totalOpens = totalOpens + self.opensRate = opensRate + } + + private enum CodingKeys: String, CodingKey { + case totalSends = "total_sends" + case uniqueOpens = "unique_opens" + case totalOpens = "total_opens" + case opensRate = "opens_rate" + } +} + +extension StatsEmailOpensData { + public init?(jsonDictionary: [String: AnyObject]) { + do { + let jsonData = try JSONSerialization.data(withJSONObject: jsonDictionary, options: []) + let decoder = JSONDecoder() + self = try decoder.decode(Self.self, from: jsonData) + } catch { + return nil + } + } +} diff --git a/Sources/WordPressKit/Models/Stats/StatsPostDetails.swift b/Sources/WordPressKit/Models/Stats/StatsPostDetails.swift index 46d5823d..214ded4f 100644 --- a/Sources/WordPressKit/Models/Stats/StatsPostDetails.swift +++ b/Sources/WordPressKit/Models/Stats/StatsPostDetails.swift @@ -1,3 +1,5 @@ +import Foundation + public struct StatsPostDetails: Equatable { public let fetchedDate: Date public let totalViewsCount: Int @@ -6,6 +8,81 @@ public struct StatsPostDetails: Equatable { public let dailyAveragesPerMonth: [StatsPostViews] public let monthlyBreakdown: [StatsPostViews] public let lastTwoWeeks: [StatsPostViews] + public let data: [StatsPostViews] + + public let highestMonth: Int? + public let highestDayAverage: Int? + public let highestWeekAverage: Int? + + public let yearlyTotals: [Int: Int] + public let overallAverages: [Int: Int] + + public let fields: [String]? + + public let post: Post? + + public struct Post: Equatable { + public let postID: Int + public let title: String + public let authorID: String? + public let dateGMT: Date? + public let content: String? + public let excerpt: String? + public let status: String? + public let commentStatus: String? + public let password: String? + public let name: String? + public let modifiedGMT: Date? + public let contentFiltered: String? + public let parent: Int? + public let guid: String? + public let type: String? + public let mimeType: String? + public let commentCount: String? + public let permalink: String? + + init?(jsonDictionary: [String: AnyObject]) { + guard + let postID = jsonDictionary["ID"] as? Int, + let title = jsonDictionary["post_title"] as? String + else { + return nil + } + + let dateFormatter = DateFormatter() + dateFormatter.locale = Locale(identifier: "en_US_POSIX") + dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss" + + var dateGMT: Date? + var modifiedGMT: Date? + + if let postDateGMTString = jsonDictionary["post_date_gmt"] as? String { + dateGMT = dateFormatter.date(from: postDateGMTString) + } + if let postModifiedGMTString = jsonDictionary["post_modified_gmt"] as? String { + modifiedGMT = dateFormatter.date(from: postModifiedGMTString) + } + + self.postID = postID + self.title = title + self.authorID = jsonDictionary["post_author"] as? String + self.dateGMT = dateGMT + self.content = jsonDictionary["post_content"] as? String + self.excerpt = jsonDictionary["post_excerpt"] as? String + self.status = jsonDictionary["post_status"] as? String + self.commentStatus = jsonDictionary["comment_status"] as? String + self.password = jsonDictionary["post_password"] as? String + self.name = jsonDictionary["post_name"] as? String + self.modifiedGMT = modifiedGMT + self.contentFiltered = jsonDictionary["post_content_filtered"] as? String + self.parent = jsonDictionary["post_parent"] as? Int + self.guid = jsonDictionary["guid"] as? String + self.type = jsonDictionary["post_type"] as? String + self.mimeType = jsonDictionary["post_mime_type"] as? String + self.commentCount = jsonDictionary["comment_count"] as? String + self.permalink = jsonDictionary["permalink"] as? String + } + } } public struct StatsWeeklyBreakdown: Equatable { @@ -15,6 +92,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 +104,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,13 +113,15 @@ 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 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 @@ -50,6 +130,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 +209,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 +249,9 @@ extension StatsPostViews { totalViewsCount: totalViews, averageViewsCount: averageViews, changePercentage: change, + isChangeInfinity: isChangeInfinity, days: mappedDays) } - } } diff --git a/Sources/WordPressKit/Models/Stats/StatsSubscribersSummaryData.swift b/Sources/WordPressKit/Models/Stats/StatsSubscribersSummaryData.swift index aece6030..db89bfc0 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_POSIX") + df.dateFormat = "yyyy-MM-dd HH:mm:ss" + return df + } + static var dateFormatter: DateFormatter = { let df = DateFormatter() df.locale = Locale(identifier: "en_US_POS") @@ -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/StatsArchiveTimeIntervalData.swift b/Sources/WordPressKit/Models/Stats/Time Interval/StatsArchiveTimeIntervalData.swift new file mode 100644 index 00000000..b990c7fc --- /dev/null +++ b/Sources/WordPressKit/Models/Stats/Time Interval/StatsArchiveTimeIntervalData.swift @@ -0,0 +1,82 @@ +import Foundation + +public struct StatsArchiveTimeIntervalData { + public let period: StatsPeriodUnit + public let unit: StatsPeriodUnit? + public let periodEndDate: Date + public let summary: [String: [StatsArchiveItem]] + + public init(period: StatsPeriodUnit, + unit: StatsPeriodUnit? = nil, + periodEndDate: Date, + summary: [String: [StatsArchiveItem]]) { + self.period = period + self.unit = unit + self.periodEndDate = periodEndDate + self.summary = summary + } +} + +public struct StatsArchiveItem { + public let href: String + public let value: String + public let views: Int + + public init(href: String, value: String, views: Int) { + self.href = href + self.value = value + self.views = views + } +} + +extension StatsArchiveTimeIntervalData: StatsTimeIntervalData { + public static var pathComponent: String { + return "stats/archives" + } + + public static func queryProperties(with date: Date, period: StatsPeriodUnit, maxCount: Int) -> [String: String] { + return ["max": String(maxCount)] + } + + public init?(date: Date, period: StatsPeriodUnit, jsonDictionary: [String: AnyObject]) { + self.init(date: date, period: period, unit: nil, jsonDictionary: jsonDictionary) + } + + public init?(date: Date, period: StatsPeriodUnit, unit: StatsPeriodUnit?, jsonDictionary: [String: AnyObject]) { + guard let summary = jsonDictionary["summary"] as? [String: AnyObject] else { + return nil + } + + self.period = period + self.unit = unit + self.periodEndDate = date + self.summary = { + var map: [String: [StatsArchiveItem]] = [:] + for (key, value) in summary { + let items = (value as? [[String: AnyObject]])?.compactMap { + StatsArchiveItem(jsonDictionary: $0) + } ?? [] + if !items.isEmpty { + map[key] = items + } + } + return map + }() + } +} + +private extension StatsArchiveItem { + init?(jsonDictionary: [String: AnyObject]) { + guard + let href = jsonDictionary["href"] as? String, + let value = jsonDictionary["value"] as? String, + let views = jsonDictionary["views"] as? Int + else { + return nil + } + + self.href = href + self.value = value + self.views = views + } +} diff --git a/Sources/WordPressKit/Models/Stats/Time Interval/StatsSiteMetricsResponse.swift b/Sources/WordPressKit/Models/Stats/Time Interval/StatsSiteMetricsResponse.swift new file mode 100644 index 00000000..7a9f9da2 --- /dev/null +++ b/Sources/WordPressKit/Models/Stats/Time Interval/StatsSiteMetricsResponse.swift @@ -0,0 +1,108 @@ +import Foundation + +public struct StatsSiteMetricsResponse { + public var period: StatsPeriodUnit + public var periodEndDate: Date + public let data: [PeriodData] + + public enum Metric: String, CaseIterable { + case views + case visitors + case likes + case comments + case posts + } + + public struct PeriodData { + /// Periods date in the site timezone. + public var date: Date + public var views: Int? + public var visitors: Int? + public var likes: Int? + public var comments: Int? + public var posts: Int? + + public subscript(metric: Metric) -> Int? { + switch metric { + case .views: views + case .visitors: visitors + case .likes: likes + case .comments: comments + case .posts: posts + } + } + } +} + +extension StatsSiteMetricsResponse: StatsTimeIntervalData { + public static var pathComponent: String { + "stats/visits" + } + + public static func queryProperties(with date: Date, period: StatsPeriodUnit, maxCount: Int) -> [String: String] { + return [ + "unit": period.stringValue, + "quantity": String(maxCount), + "stat_fields": Metric.allCases.map(\.rawValue).joined(separator: ",") + ] + } + + public init?(date: Date, period: StatsPeriodUnit, jsonDictionary: [String: AnyObject]) { + self.init(date: date, period: period, unit: nil, jsonDictionary: jsonDictionary) + } + + public init?(date: Date, period: StatsPeriodUnit, unit: StatsPeriodUnit?, jsonDictionary: [String: AnyObject]) { + guard let fields = jsonDictionary["fields"] as? [String], + let data = jsonDictionary["data"] as? [[Any]] else { + return nil + } + + guard let periodIndex = fields.firstIndex(of: "period") else { + return nil + } + + self.period = period + self.periodEndDate = date + + let indices = ( + views: fields.firstIndex(of: Metric.views.rawValue), + visitors: fields.firstIndex(of: Metric.visitors.rawValue), + likes: fields.firstIndex(of: Metric.likes.rawValue), + comments: fields.firstIndex(of: Metric.comments.rawValue), + posts: fields.firstIndex(of: Metric.posts.rawValue) + ) + + let dateFormatter = makeDateFormatter(for: period) + + self.data = data.compactMap { data in + guard let date = dateFormatter.date(from: data[periodIndex] as? String ?? "") else { + return nil + } + func getValue(at index: Int?) -> Int? { + guard let index else { return nil } + return data[index] as? Int + } + return PeriodData( + date: date, + views: getValue(at: indices.views), + visitors: getValue(at: indices.visitors), + likes: getValue(at: indices.likes), + comments: getValue(at: indices.comments), + posts: getValue(at: indices.posts) + ) + } + } +} + +private func makeDateFormatter(for unit: StatsPeriodUnit) -> DateFormatter { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = { + switch unit { + case .hour: "yyyy-MM-dd HH:mm:ss" + case .week: "yyyy'W'MM'W'dd" + case .day, .month, .year: "yyyy-MM-dd" + } + }() + return formatter +} diff --git a/Sources/WordPressKit/Models/Stats/Time Interval/StatsSummaryTimeIntervalData.swift b/Sources/WordPressKit/Models/Stats/Time Interval/StatsSummaryTimeIntervalData.swift index acefd4b7..d8b158c3 100644 --- a/Sources/WordPressKit/Models/Stats/Time Interval/StatsSummaryTimeIntervalData.swift +++ b/Sources/WordPressKit/Models/Stats/Time Interval/StatsSummaryTimeIntervalData.swift @@ -1,8 +1,11 @@ +import Foundation + @frozen public enum StatsPeriodUnit: Int { case day case week case month case year + case hour } @frozen public enum StatsSummaryType: Int { @@ -177,6 +180,9 @@ private extension StatsSummaryData { static func parsedDate(from dateString: String, for period: StatsPeriodUnit) -> Date? { switch period { + case .hour: + assertionFailure("Unsupported time period") + return nil case .week: return self.weeksDateFormatter.date(from: dateString) case .day, .month, .year: 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 02b9ce94..050f918b 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 { @@ -24,6 +25,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 @@ -99,17 +107,36 @@ 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, - 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) - let staticProperties = ["period": period.stringValue, + 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["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] @@ -117,12 +144,11 @@ open class StatsServiceRemoteV2: ServiceRemoteWordPressComREST { return val1 } - wordPressComRESTAPI.get(path, parameters: properties, success: { [weak self] (response, _) in + 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 @@ -134,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) @@ -277,6 +304,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: @@ -342,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 { @@ -381,14 +433,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 } - return firstDay + if let days = jsonDictionary["days"] as? [String: AnyObject], + let firstKey = days.keys.first, + let firstDay = days[firstKey] as? [String: AnyObject] { + return firstDay + } + return nil } } @@ -398,6 +451,8 @@ extension StatsTimeIntervalData { public extension StatsPeriodUnit { var stringValue: String { switch self { + case .hour: + return "hour" case .day: return "day" case .week: @@ -411,6 +466,8 @@ public extension StatsPeriodUnit { init?(string: String) { switch string { + case "hour": + self = .hour case "day": self = .day case "week": 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 } } 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/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/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/Mock Data/stats-visits-hourly.json b/Tests/WordPressKitTests/Mock Data/stats-visits-hourly.json new file mode 100644 index 00000000..ef412c43 --- /dev/null +++ b/Tests/WordPressKitTests/Mock Data/stats-visits-hourly.json @@ -0,0 +1,182 @@ +{ + "date": "2025-07-18 00:00:00", + "unit": "hour", + "fields": [ + "period", + "views", + "visitors", + "comments", + "likes" + ], + "data": [ + [ + "2025-07-17 00:00:00", + 5140, + 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", + 3244 + , + 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 95476038..fb5a1d30 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" @@ -25,6 +26,8 @@ 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" + let getEmailOpensFilename = "stats-email-opens.json" // MARK: - Properties @@ -41,6 +44,8 @@ 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" } + var siteEmailOpensEndpoint: String { return "sites/\(siteID)/stats/opens/emails/231/rate" } func toggleSpamStateEndpoint(for referrerDomain: String, markAsSpam: Bool) -> String { let action = markAsSpam ? "new" : "delete" @@ -436,6 +441,35 @@ 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: .hour, endingOn: date) { (summary: StatsSiteMetricsResponse?, error: Error?) in + XCTAssertNil(error) + XCTAssertNotNil(summary) + + XCTAssertEqual(summary?.data.count, 24) + + XCTAssertEqual(summary?.data[0].views, 5140) + XCTAssertNil(summary?.data[0].visitors) + XCTAssertNil(summary?.data[0].likes) + XCTAssertNil(summary?.data[0].comments) + + XCTAssertEqual(summary?.data[9].views, 3244) + XCTAssertNil(summary?.data[9].likes) + XCTAssertNil(summary?.data[9].comments) + + expect.fulfill() + } + + waitForExpectations(timeout: timeout, handler: nil) + } + func testFetchPostDetail() { let expect = expectation(description: "It should return post detail") @@ -486,6 +520,7 @@ class StatsRemoteV2Tests: RemoteTestCase, RESTTestable { 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) @@ -497,6 +532,7 @@ class StatsRemoteV2Tests: RemoteTestCase, RESTTestable { 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) @@ -505,6 +541,45 @@ class StatsRemoteV2Tests: RemoteTestCase, RESTTestable { 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() } @@ -539,9 +614,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() } @@ -626,34 +703,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: StatsSiteMetricsResponse?, error: Error?) in XCTAssertNil(error) - XCTAssertNotNil(summary) - - XCTAssertEqual(summary?.summaryData.count, 10) - - XCTAssertEqual(summary?.summaryData[0].viewsCount, 0) - XCTAssertEqual(summary?.summaryData[0].visitorsCount, 0) - XCTAssertEqual(summary?.summaryData[0].likesCount, 72) - XCTAssertEqual(summary?.summaryData[0].commentsCount, 0) - - 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) - XCTAssertEqual(summary?.summaryData[9].likesCount, 116) - XCTAssertEqual(summary?.summaryData[9].commentsCount, 0) + XCTAssertNotNil(stats) - let nineMonthsFromMay1 = Calendar.autoupdatingCurrent.date(byAdding: .month, value: 9, to: may1Date)! + if let data = stats?.data, data.count == 24 { - XCTAssertEqual(summary?.summaryData[9].periodStartDate, nineMonthsFromMay1) + XCTAssertEqual(data[0].views, 5140) + XCTAssertNil(data[0].comments) + XCTAssertEqual(data[1].views, 2) + XCTAssertNil(data[1].comments) + } else { + XCTFail("unexpected count") + } expect.fulfill() } @@ -714,4 +780,78 @@ class StatsRemoteV2Tests: RemoteTestCase, RESTTestable { waitForExpectations(timeout: timeout, handler: nil) } + + func testArchives() throws { + 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)! + + var returnValue: StatsArchiveTimeIntervalData? + remote.getData(for: .day, endingOn: date) { (value: StatsArchiveTimeIntervalData?, error: Error?) in + XCTAssertNil(error) + returnValue = value + expect.fulfill() + } + waitForExpectations(timeout: timeout, handler: nil) + + let archives = try XCTUnwrap(returnValue) + + XCTAssertEqual(archives.period, .day) + XCTAssertEqual(archives.periodEndDate, date) + + // Test other items + let other = try XCTUnwrap(archives.summary["other"]) + XCTAssertEqual(other.count, 8) + + 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) + + XCTAssertEqual(other[1].href, "http://example.com/wp-admin/") + XCTAssertEqual(other[1].value, "/wp-admin/") + XCTAssertEqual(other[1].views, 4) + + XCTAssertEqual(other.last?.href, "http://example.com/wp-admin/profile.php") + XCTAssertEqual(other.last?.value, "/wp-admin/profile.php") + XCTAssertEqual(other.last?.views, 1) + + // Test author items + let author = try XCTUnwrap(archives.summary["author"]) + XCTAssertEqual(author.count, 4) + + XCTAssertEqual(author.first?.href, "http://example.com/author/johndoe/") + XCTAssertEqual(author.first?.value, "johndoe") + XCTAssertEqual(author.first?.views, 31) + + XCTAssertEqual(author[1].href, "http://example.com/author/janedoe/") + XCTAssertEqual(author[1].value, "janedoe") + XCTAssertEqual(author[1].views, 5) + + XCTAssertEqual(author.last?.href, "http://example.com/author//") + 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 e028239c..e68f8d5b 100644 --- a/WordPressKit.xcodeproj/project.pbxproj +++ b/WordPressKit.xcodeproj/project.pbxproj @@ -9,17 +9,21 @@ /* 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 */; }; 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 */; }; @@ -45,6 +49,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 /* 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 */; }; 0CB190652A2A7569004D3E80 /* BlazeCampaignsSearchResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CB190642A2A7569004D3E80 /* BlazeCampaignsSearchResponse.swift */; }; @@ -794,17 +800,21 @@ /* 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 = ""; }; 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 = ""; }; @@ -829,6 +839,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 /* 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 = ""; }; 0CB190642A2A7569004D3E80 /* BlazeCampaignsSearchResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlazeCampaignsSearchResponse.swift; sourceTree = ""; }; @@ -1609,6 +1621,7 @@ isa = PBXGroup; children = ( 019C5B892BD59CE000A69DB0 /* StatsEmailsSummaryData.swift */, + 11B3582686AD49E591C8615E /* StatsEmailOpensData.swift */, ); path = Emails; sourceTree = ""; @@ -2274,11 +2287,13 @@ 40819772221E10C900A298E4 /* StatsPublishedPostsTimeIntervalData.swift */, 404057C4221B30400060250C /* StatsSearchTermTimeIntervalData.swift */, 40819777221F00E600A298E4 /* StatsSummaryTimeIntervalData.swift */, + 0CAD702F2E2C017500EFD4BC /* StatsSiteMetricsResponse.swift */, 404057C8221B789B0060250C /* StatsTopAuthorsTimeIntervalData.swift */, 404057D5221C92660060250C /* StatsTopClicksTimeIntervalData.swift */, 404057D1221C56AB0060250C /* StatsTopCountryTimeIntervalData.swift */, 4081976E221DDE9B00A298E4 /* StatsTopPostsTimeIntervalData.swift */, 404057D9221C9D560060250C /* StatsTopReferrersTimeIntervalData.swift */, + 0C31499A2E2FFBA100AAF9DF /* StatsArchiveTimeIntervalData.swift */, 404057CD221C38130060250C /* StatsTopVideosTimeIntervalData.swift */, ); path = "Time Interval"; @@ -2604,11 +2619,14 @@ 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 */, 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 */, @@ -3073,6 +3091,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 */, @@ -3208,6 +3227,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 */, @@ -3283,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; }; @@ -3369,7 +3390,9 @@ 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 */, + 11B3582786AD49E591C8615E /* StatsEmailOpensData.swift in Sources */, 9856BE962630B5C200C12FEB /* RemoteUser+Likes.swift in Sources */, 3FD634E52BC3A55F00CEDF5E /* WordPressOrgXMLRPCValidator.swift in Sources */, 3FE2E97B2BC3A332002CA2E1 /* WordPressAPIError+NSErrorBridge.swift in Sources */, @@ -3535,6 +3558,7 @@ 0CCD4C5C2C41700B00B53F9A /* UIDevice+Extensions.swift in Sources */, 74A44DD11F13C64B006CD8F4 /* RemoteNotificationSettings.swift in Sources */, FEF7419D28085D89002C4203 /* RemoteBloggingPrompt.swift in Sources */, + 0CAD70302E2C017500EFD4BC /* StatsSiteMetricsResponse.swift in Sources */, 74DA56331F06EAF000FE9BF4 /* MediaServiceRemoteREST.m in Sources */, 17CD0CC320C58A0D000D9620 /* ReaderSiteSearchServiceRemote.swift in Sources */, 74DA563B1F06EB3000FE9BF4 /* RemoteMedia.m in Sources */,