Skip to content
This repository was archived by the owner on Sep 15, 2025. It is now read-only.

Commit 7d8c756

Browse files
authored
New Stats (#843)
2 parents 2b7d4f6 + 440d94e commit 7d8c756

17 files changed

+944
-67
lines changed

Package.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,8 @@ let package = Package(
1111
targets: [
1212
.binaryTarget(
1313
name: "WordPressKit",
14-
url: "https://github.com/user-attachments/files/21518814/WordPressKit.zip",
15-
checksum: "a43e82909d851e78dff7fa64edba12635e2e96206c1fcdca075eae02dd4c157e"
14+
url: "https://github.com/user-attachments/files/21582269/WordPressKit.zip",
15+
checksum: "cbfe79d7a4244302d308027ff329f1ccdfd1c604d990871359764eca567ea86f"
1616
),
1717
]
1818
)
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import Foundation
2+
3+
public struct StatsEmailOpensData: Decodable, Equatable {
4+
public let totalSends: Int?
5+
public let uniqueOpens: Int?
6+
public let totalOpens: Int?
7+
public let opensRate: Double?
8+
9+
public init(totalSends: Int?, uniqueOpens: Int?, totalOpens: Int?, opensRate: Double?) {
10+
self.totalSends = totalSends
11+
self.uniqueOpens = uniqueOpens
12+
self.totalOpens = totalOpens
13+
self.opensRate = opensRate
14+
}
15+
16+
private enum CodingKeys: String, CodingKey {
17+
case totalSends = "total_sends"
18+
case uniqueOpens = "unique_opens"
19+
case totalOpens = "total_opens"
20+
case opensRate = "opens_rate"
21+
}
22+
}
23+
24+
extension StatsEmailOpensData {
25+
public init?(jsonDictionary: [String: AnyObject]) {
26+
do {
27+
let jsonData = try JSONSerialization.data(withJSONObject: jsonDictionary, options: [])
28+
let decoder = JSONDecoder()
29+
self = try decoder.decode(Self.self, from: jsonData)
30+
} catch {
31+
return nil
32+
}
33+
}
34+
}

Sources/WordPressKit/Models/Stats/StatsPostDetails.swift

Lines changed: 136 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import Foundation
2+
13
public struct StatsPostDetails: Equatable {
24
public let fetchedDate: Date
35
public let totalViewsCount: Int
@@ -6,6 +8,81 @@ public struct StatsPostDetails: Equatable {
68
public let dailyAveragesPerMonth: [StatsPostViews]
79
public let monthlyBreakdown: [StatsPostViews]
810
public let lastTwoWeeks: [StatsPostViews]
11+
public let data: [StatsPostViews]
12+
13+
public let highestMonth: Int?
14+
public let highestDayAverage: Int?
15+
public let highestWeekAverage: Int?
16+
17+
public let yearlyTotals: [Int: Int]
18+
public let overallAverages: [Int: Int]
19+
20+
public let fields: [String]?
21+
22+
public let post: Post?
23+
24+
public struct Post: Equatable {
25+
public let postID: Int
26+
public let title: String
27+
public let authorID: String?
28+
public let dateGMT: Date?
29+
public let content: String?
30+
public let excerpt: String?
31+
public let status: String?
32+
public let commentStatus: String?
33+
public let password: String?
34+
public let name: String?
35+
public let modifiedGMT: Date?
36+
public let contentFiltered: String?
37+
public let parent: Int?
38+
public let guid: String?
39+
public let type: String?
40+
public let mimeType: String?
41+
public let commentCount: String?
42+
public let permalink: String?
43+
44+
init?(jsonDictionary: [String: AnyObject]) {
45+
guard
46+
let postID = jsonDictionary["ID"] as? Int,
47+
let title = jsonDictionary["post_title"] as? String
48+
else {
49+
return nil
50+
}
51+
52+
let dateFormatter = DateFormatter()
53+
dateFormatter.locale = Locale(identifier: "en_US_POSIX")
54+
dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
55+
56+
var dateGMT: Date?
57+
var modifiedGMT: Date?
58+
59+
if let postDateGMTString = jsonDictionary["post_date_gmt"] as? String {
60+
dateGMT = dateFormatter.date(from: postDateGMTString)
61+
}
62+
if let postModifiedGMTString = jsonDictionary["post_modified_gmt"] as? String {
63+
modifiedGMT = dateFormatter.date(from: postModifiedGMTString)
64+
}
65+
66+
self.postID = postID
67+
self.title = title
68+
self.authorID = jsonDictionary["post_author"] as? String
69+
self.dateGMT = dateGMT
70+
self.content = jsonDictionary["post_content"] as? String
71+
self.excerpt = jsonDictionary["post_excerpt"] as? String
72+
self.status = jsonDictionary["post_status"] as? String
73+
self.commentStatus = jsonDictionary["comment_status"] as? String
74+
self.password = jsonDictionary["post_password"] as? String
75+
self.name = jsonDictionary["post_name"] as? String
76+
self.modifiedGMT = modifiedGMT
77+
self.contentFiltered = jsonDictionary["post_content_filtered"] as? String
78+
self.parent = jsonDictionary["post_parent"] as? Int
79+
self.guid = jsonDictionary["guid"] as? String
80+
self.type = jsonDictionary["post_type"] as? String
81+
self.mimeType = jsonDictionary["post_mime_type"] as? String
82+
self.commentCount = jsonDictionary["comment_count"] as? String
83+
self.permalink = jsonDictionary["permalink"] as? String
84+
}
85+
}
986
}
1087

1188
public struct StatsWeeklyBreakdown: Equatable {
@@ -15,6 +92,7 @@ public struct StatsWeeklyBreakdown: Equatable {
1592
public let totalViewsCount: Int
1693
public let averageViewsCount: Int
1794
public let changePercentage: Double
95+
public let isChangeInfinity: Bool
1896

1997
public let days: [StatsPostViews]
2098
}
@@ -26,7 +104,7 @@ public struct StatsPostViews: Equatable {
26104
}
27105

28106
extension StatsPostDetails {
29-
init?(jsonDictionary: [String: AnyObject]) {
107+
public init?(jsonDictionary: [String: AnyObject]) {
30108
guard
31109
let fetchedDateString = jsonDictionary["date"] as? String,
32110
let date = type(of: self).dateFormatter.date(from: fetchedDateString),
@@ -35,13 +113,15 @@ extension StatsPostDetails {
35113
let monthlyAverages = jsonDictionary["averages"] as? [String: AnyObject],
36114
let recentWeeks = jsonDictionary["weeks"] as? [[String: AnyObject]],
37115
let data = jsonDictionary["data"] as? [[Any]]
38-
else {
39-
return nil
116+
else {
117+
return nil
40118
}
41119

42120
self.fetchedDate = date
43121
self.totalViewsCount = totalViewsCount
44122

123+
self.data = StatsPostViews.mapDailyData(data: data)
124+
45125
// It's very hard to describe the format of this response. I tried to make the parsing
46126
// as nice and readable as possible, but in all honestly it's still pretty nasty.
47127
// If you want to see an example response to see how weird this response is, check out
@@ -50,6 +130,42 @@ extension StatsPostDetails {
50130
self.monthlyBreakdown = StatsPostViews.mapMonthlyBreakdown(jsonDictionary: monthlyBreakdown)
51131
self.dailyAveragesPerMonth = StatsPostViews.mapMonthlyBreakdown(jsonDictionary: monthlyAverages)
52132
self.lastTwoWeeks = StatsPostViews.mapDailyData(data: Array(data.suffix(14)))
133+
134+
// Parse new fields
135+
self.highestMonth = jsonDictionary["highest_month"] as? Int
136+
self.highestDayAverage = jsonDictionary["highest_day_average"] as? Int
137+
self.highestWeekAverage = jsonDictionary["highest_week_average"] as? Int
138+
139+
self.fields = jsonDictionary["fields"] as? [String]
140+
141+
// Parse yearly totals
142+
var yearlyTotals: [Int: Int] = [:]
143+
if let years = monthlyBreakdown as? [String: [String: AnyObject]] {
144+
for (yearKey, yearData) in years {
145+
if let yearInt = Int(yearKey), let total = yearData["total"] as? Int {
146+
yearlyTotals[yearInt] = total
147+
}
148+
}
149+
}
150+
self.yearlyTotals = yearlyTotals
151+
152+
// Parse overall averages
153+
var overallAverages: [Int: Int] = [:]
154+
if let averages = monthlyAverages as? [String: [String: AnyObject]] {
155+
for (yearKey, yearData) in averages {
156+
if let yearInt = Int(yearKey), let overall = yearData["overall"] as? Int {
157+
overallAverages[yearInt] = overall
158+
}
159+
}
160+
}
161+
self.overallAverages = overallAverages
162+
163+
// Parse post object using the new Post model
164+
if let postDict = jsonDictionary["post"] as? [String: AnyObject] {
165+
self.post = Post(jsonDictionary: postDict)
166+
} else {
167+
self.post = nil
168+
}
53169
}
54170

55171
static var dateFormatter: DateFormatter {
@@ -93,19 +209,30 @@ extension StatsPostViews {
93209
let totalViews = $0["total"] as? Int,
94210
let averageViews = $0["average"] as? Int,
95211
let days = $0["days"] as? [[String: AnyObject]]
96-
else {
97-
return nil
212+
else {
213+
return nil
98214
}
99215

100-
let change = ($0["change"] as? Double) ?? 0.0
216+
var change: Double = 0.0
217+
var isChangeInfinity = false
218+
219+
if let changeValue = $0["change"] {
220+
if let changeDict = changeValue as? [String: AnyObject],
221+
let isInfinity = changeDict["isInfinity"] as? Bool {
222+
isChangeInfinity = isInfinity
223+
change = isInfinity ? Double.infinity : 0.0
224+
} else if let changeDouble = changeValue as? Double {
225+
change = changeDouble
226+
}
227+
}
101228

102229
let mappedDays: [StatsPostViews] = days.compactMap {
103230
guard
104231
let dayString = $0["day"] as? String,
105232
let date = StatsPostDetails.dateFormatter.date(from: dayString),
106233
let viewsCount = $0["count"] as? Int
107-
else {
108-
return nil
234+
else {
235+
return nil
109236
}
110237

111238
return StatsPostViews(period: .day,
@@ -122,9 +249,9 @@ extension StatsPostViews {
122249
totalViewsCount: totalViews,
123250
averageViewsCount: averageViews,
124251
changePercentage: change,
252+
isChangeInfinity: isChangeInfinity,
125253
days: mappedDays)
126254
}
127-
128255
}
129256
}
130257

Sources/WordPressKit/Models/Stats/StatsSubscribersSummaryData.swift

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,13 @@ extension StatsSubscribersSummaryData: StatsTimeIntervalData {
1717
return "stats/subscribers"
1818
}
1919

20+
static var hourlyDateFormatter: DateFormatter {
21+
let df = DateFormatter()
22+
df.locale = Locale(identifier: "en_US_POSIX")
23+
df.dateFormat = "yyyy-MM-dd HH:mm:ss"
24+
return df
25+
}
26+
2027
static var dateFormatter: DateFormatter = {
2128
let df = DateFormatter()
2229
df.locale = Locale(identifier: "en_US_POS")
@@ -71,6 +78,9 @@ extension StatsSubscribersSummaryData: StatsTimeIntervalData {
7178

7279
private static func parsedDate(from dateString: String, for period: StatsPeriodUnit) -> Date? {
7380
switch period {
81+
case .hour:
82+
// Example: "2025-07-17 09:00:00" (in a site timezone)
83+
return self.hourlyDateFormatter.date(from: dateString)
7484
case .week:
7585
return self.weeksDateFormatter.date(from: dateString)
7686
case .day, .month, .year:
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import Foundation
2+
3+
public struct StatsArchiveTimeIntervalData {
4+
public let period: StatsPeriodUnit
5+
public let unit: StatsPeriodUnit?
6+
public let periodEndDate: Date
7+
public let summary: [String: [StatsArchiveItem]]
8+
9+
public init(period: StatsPeriodUnit,
10+
unit: StatsPeriodUnit? = nil,
11+
periodEndDate: Date,
12+
summary: [String: [StatsArchiveItem]]) {
13+
self.period = period
14+
self.unit = unit
15+
self.periodEndDate = periodEndDate
16+
self.summary = summary
17+
}
18+
}
19+
20+
public struct StatsArchiveItem {
21+
public let href: String
22+
public let value: String
23+
public let views: Int
24+
25+
public init(href: String, value: String, views: Int) {
26+
self.href = href
27+
self.value = value
28+
self.views = views
29+
}
30+
}
31+
32+
extension StatsArchiveTimeIntervalData: StatsTimeIntervalData {
33+
public static var pathComponent: String {
34+
return "stats/archives"
35+
}
36+
37+
public static func queryProperties(with date: Date, period: StatsPeriodUnit, maxCount: Int) -> [String: String] {
38+
return ["max": String(maxCount)]
39+
}
40+
41+
public init?(date: Date, period: StatsPeriodUnit, jsonDictionary: [String: AnyObject]) {
42+
self.init(date: date, period: period, unit: nil, jsonDictionary: jsonDictionary)
43+
}
44+
45+
public init?(date: Date, period: StatsPeriodUnit, unit: StatsPeriodUnit?, jsonDictionary: [String: AnyObject]) {
46+
guard let summary = jsonDictionary["summary"] as? [String: AnyObject] else {
47+
return nil
48+
}
49+
50+
self.period = period
51+
self.unit = unit
52+
self.periodEndDate = date
53+
self.summary = {
54+
var map: [String: [StatsArchiveItem]] = [:]
55+
for (key, value) in summary {
56+
let items = (value as? [[String: AnyObject]])?.compactMap {
57+
StatsArchiveItem(jsonDictionary: $0)
58+
} ?? []
59+
if !items.isEmpty {
60+
map[key] = items
61+
}
62+
}
63+
return map
64+
}()
65+
}
66+
}
67+
68+
private extension StatsArchiveItem {
69+
init?(jsonDictionary: [String: AnyObject]) {
70+
guard
71+
let href = jsonDictionary["href"] as? String,
72+
let value = jsonDictionary["value"] as? String,
73+
let views = jsonDictionary["views"] as? Int
74+
else {
75+
return nil
76+
}
77+
78+
self.href = href
79+
self.value = value
80+
self.views = views
81+
}
82+
}

0 commit comments

Comments
 (0)