diff --git a/WordPressKit.xcodeproj/project.pbxproj b/WordPressKit.xcodeproj/project.pbxproj index 87b0fb18..644d9588 100644 --- a/WordPressKit.xcodeproj/project.pbxproj +++ b/WordPressKit.xcodeproj/project.pbxproj @@ -129,6 +129,8 @@ 4A68E3D329406AA0004AC3DC /* RemoteMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A68E3D029406AA0004AC3DC /* RemoteMenu.swift */; }; 4A68E3D429406AA0004AC3DC /* RemoteMenuItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A68E3D129406AA0004AC3DC /* RemoteMenuItem.swift */; }; 4A68E3D529406AA0004AC3DC /* RemoteMenuLocation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A68E3D229406AA0004AC3DC /* RemoteMenuLocation.swift */; }; + 4A68E40B294922A8004AC3DC /* RemoteReaderPost.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A68E40A294922A8004AC3DC /* RemoteReaderPost.swift */; }; + 4A68E40D294930CC004AC3DC /* RemoteReaderPostTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A68E40C294930CC004AC3DC /* RemoteReaderPostTests.swift */; }; 4A68E3D729406DA2004AC3DC /* RemoteUser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A68E3D629406DA2004AC3DC /* RemoteUser.swift */; }; 4A68E3D929406E0D004AC3DC /* RemoteTheme.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A68E3D829406E0D004AC3DC /* RemoteTheme.swift */; }; 4A68E3DB29406EA0004AC3DC /* RemoteSourcePostAttribution.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A68E3DA29406EA0004AC3DC /* RemoteSourcePostAttribution.swift */; }; @@ -346,7 +348,7 @@ 82FFBF521F45F04100F4573F /* RemoteBlogJetpackMonitorSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82FFBF511F45F04100F4573F /* RemoteBlogJetpackMonitorSettings.swift */; }; 82FFBF561F460DD400F4573F /* BlogJetpackSettingsServiceRemote.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82FFBF551F460DD400F4573F /* BlogJetpackSettingsServiceRemote.swift */; }; 8B074A4E27AC2FFD003A2EB8 /* dashboard-400-invalid-card.json in Resources */ = {isa = PBXBuildFile; fileRef = 8B074A4D27AC2FFD003A2EB8 /* dashboard-400-invalid-card.json */; }; - 8B16CE8E25250039007BE5A9 /* RemoteReaderPost.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B16CE8D25250039007BE5A9 /* RemoteReaderPost.swift */; }; + 8B16CE8E25250039007BE5A9 /* ReaderPostsEnvelope.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B16CE8D25250039007BE5A9 /* ReaderPostsEnvelope.swift */; }; 8B16CE92252502C4007BE5A9 /* RemoteReaderPostTests+V2.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B16CE91252502C4007BE5A9 /* RemoteReaderPostTests+V2.swift */; }; 8B16CE962525045F007BE5A9 /* reader-posts-success.json in Resources */ = {isa = PBXBuildFile; fileRef = 8B16CE952525045F007BE5A9 /* reader-posts-success.json */; }; 8B2F4BE524ABB3C70056C08A /* RemoteReaderPostTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 8B2F4BE424ABB3C70056C08A /* RemoteReaderPostTests.m */; }; @@ -775,6 +777,8 @@ 4A68E3D029406AA0004AC3DC /* RemoteMenu.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteMenu.swift; sourceTree = ""; }; 4A68E3D129406AA0004AC3DC /* RemoteMenuItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteMenuItem.swift; sourceTree = ""; }; 4A68E3D229406AA0004AC3DC /* RemoteMenuLocation.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteMenuLocation.swift; sourceTree = ""; }; + 4A68E40A294922A8004AC3DC /* RemoteReaderPost.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteReaderPost.swift; sourceTree = ""; }; + 4A68E40C294930CC004AC3DC /* RemoteReaderPostTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteReaderPostTests.swift; sourceTree = ""; }; 4A68E3D629406DA2004AC3DC /* RemoteUser.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteUser.swift; sourceTree = ""; }; 4A68E3D829406E0D004AC3DC /* RemoteTheme.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteTheme.swift; sourceTree = ""; }; 4A68E3DA29406EA0004AC3DC /* RemoteSourcePostAttribution.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteSourcePostAttribution.swift; sourceTree = ""; }; @@ -994,7 +998,7 @@ 82FFBF511F45F04100F4573F /* RemoteBlogJetpackMonitorSettings.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteBlogJetpackMonitorSettings.swift; sourceTree = ""; }; 82FFBF551F460DD400F4573F /* BlogJetpackSettingsServiceRemote.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BlogJetpackSettingsServiceRemote.swift; sourceTree = ""; }; 8B074A4D27AC2FFD003A2EB8 /* dashboard-400-invalid-card.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "dashboard-400-invalid-card.json"; sourceTree = ""; }; - 8B16CE8D25250039007BE5A9 /* RemoteReaderPost.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteReaderPost.swift; sourceTree = ""; }; + 8B16CE8D25250039007BE5A9 /* ReaderPostsEnvelope.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderPostsEnvelope.swift; sourceTree = ""; }; 8B16CE91252502C4007BE5A9 /* RemoteReaderPostTests+V2.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RemoteReaderPostTests+V2.swift"; sourceTree = ""; }; 8B16CE952525045F007BE5A9 /* reader-posts-success.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "reader-posts-success.json"; sourceTree = ""; }; 8B2F4BE424ABB3C70056C08A /* RemoteReaderPostTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = RemoteReaderPostTests.m; sourceTree = ""; }; @@ -1489,6 +1493,7 @@ 7430C9BB1F192C0F0051B8E6 /* ReaderTopicServiceRemoteTests.m */, 17CE77F320C701C8001DEA5A /* ReaderSiteSearchServiceRemoteTests.swift */, 8B2F4BE424ABB3C70056C08A /* RemoteReaderPostTests.m */, + 4A68E40C294930CC004AC3DC /* RemoteReaderPostTests.swift */, 8B2F4BE824ABC9DC0056C08A /* ReaderPostServiceRemote+CardsTests.swift */, FACBDD3725ECB4480026705B /* ReaderPostServiceRemote+RelatedPostsTests.swift */, FA87FE0624EB39C4003FBEE3 /* ReaderPostServiceRemote+SubscriptionTests.swift */, @@ -1927,11 +1932,12 @@ 74E2294F1F1E741B0085F7F2 /* RemotePublicizeConnection.swift */, 74E2294D1F1E73FE0085F7F2 /* RemotePublicizeService.swift */, 7430C9D61F1933200051B8E6 /* RemoteReaderCrossPostMeta.swift */, - 8B16CE8D25250039007BE5A9 /* RemoteReaderPost.swift */, + 8B16CE8D25250039007BE5A9 /* ReaderPostsEnvelope.swift */, 8B2F4BEE24ACCC120056C08A /* RemoteReaderCard.swift */, 8B2F4BF024ACE3C30056C08A /* RemoteReaderInterest.swift */, 7430C9A91F1927C50051B8E6 /* RemoteReaderPost.h */, 7430C9AA1F1927C50051B8E6 /* RemoteReaderPost.m */, + 4A68E40A294922A8004AC3DC /* RemoteReaderPost.swift */, FACBDD1D25ECA7F90026705B /* RemoteReaderSimplePost.swift */, 4A68E3DC294070A7004AC3DC /* RemoteReaderSite.swift */, 4A68E3E0294076C1004AC3DC /* RemoteReaderSiteInfo.swift */, @@ -3006,7 +3012,7 @@ F9E56DF624EB11EF00916770 /* FeatureFlag.swift in Sources */, 8B2F4BE724ABC8A90056C08A /* ReaderPostServiceRemote+Cards.swift in Sources */, 4A68E3D529406AA0004AC3DC /* RemoteMenuLocation.swift in Sources */, - 8B16CE8E25250039007BE5A9 /* RemoteReaderPost.swift in Sources */, + 8B16CE8E25250039007BE5A9 /* ReaderPostsEnvelope.swift in Sources */, 74E2294E1F1E73FE0085F7F2 /* RemotePublicizeService.swift in Sources */, 8B749DED25AF3E4600023F03 /* JetpackCapabilitiesServiceRemote.swift in Sources */, 9F3E0BA22087345F009CB5BA /* ServiceRequest.swift in Sources */, @@ -3026,6 +3032,7 @@ 9F3E0B9E208733C3009CB5BA /* ReaderServiceDeliveryFrequency.swift in Sources */, 74E2295E1F1E777B0085F7F2 /* RemoteSharingButton.swift in Sources */, 93BD27701EE737A8002BB00B /* ServiceRemoteWordPressComREST.m in Sources */, + 4A68E40B294922A8004AC3DC /* RemoteReaderPost.swift in Sources */, E61A51A621B172A900A5F902 /* RemoteWpcomPlan.swift in Sources */, 93BD277F1EE73944002BB00B /* WordPressComOAuthClient.swift in Sources */, 740B23B91F17EC7300067A2A /* PostServiceRemoteREST.m in Sources */, @@ -3235,6 +3242,7 @@ 803DE81128FFA9C4007D4E9C /* RemoteConfigRemoteTests.swift in Sources */, 74B5F0DE1EF82A9600B411E7 /* BlogServiceRemoteRESTTests.m in Sources */, ABD95B7F25DD6C4B00735BEE /* CommentServiceRemoteRESTLikesTests.swift in Sources */, + 4A68E40D294930CC004AC3DC /* RemoteReaderPostTests.swift in Sources */, 8B749E8225AF7DDA00023F03 /* JetpackCapabilitiesServiceRemoteTests.swift in Sources */, 74E2294B1F1E73340085F7F2 /* SharingServiceRemoteTests.m in Sources */, FEFFD99B26C1598F00F34231 /* ShareAppContentServiceRemoteTests.swift in Sources */, diff --git a/WordPressKit/ReaderPostsEnvelope.swift b/WordPressKit/ReaderPostsEnvelope.swift new file mode 100644 index 00000000..f3c99989 --- /dev/null +++ b/WordPressKit/ReaderPostsEnvelope.swift @@ -0,0 +1,17 @@ +import Foundation + +struct ReaderPostsEnvelope: Decodable { + var posts: [RemoteReaderPost] + var nextPageHandle: String? + + private enum CodingKeys: String, CodingKey { + case posts + case nextPageHandle = "next_page_handle" + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let postDictionary = try container.decode([String: Any].self, forKey: .posts) + posts = [RemoteReaderPost(dictionary: postDictionary)] + } +} diff --git a/WordPressKit/RemoteReaderPost.m b/WordPressKit/RemoteReaderPost.m index f8f35ae8..25d6c05c 100644 --- a/WordPressKit/RemoteReaderPost.m +++ b/WordPressKit/RemoteReaderPost.m @@ -16,7 +16,6 @@ NSString * const PostRESTKeyDiscoverMetadata = @"discover_metadata"; NSString * const PostRESTKeyDiscussion = @"discussion"; NSString * const PostRESTKeyEditorial = @"editorial"; -NSString * const PostRESTKeyEmail = @"email"; NSString * const PostRESTKeyExcerpt = @"excerpt"; NSString * const PostRESTKeyFeaturedMedia = @"featured_media"; NSString * const PostRESTKeyFeaturedImage = @"featured_image"; @@ -41,10 +40,6 @@ NSString * const PostRESTKeyScore = @"score"; NSString * const PostRESTKeySharingEnabled = @"sharing_enabled"; NSString * const PostRESTKeySiteID = @"site_ID"; -NSString * const PostRESTKeySiteIsAtomic = @"site_is_atomic"; -NSString * const PostRESTKeySiteIsPrivate = @"site_is_private"; -NSString * const PostRESTKeySiteName = @"site_name"; -NSString * const PostRESTKeySiteURL = @"site_URL"; NSString * const PostRESTKeySlug = @"slug"; NSString * const PostRESTKeyStatus = @"status"; NSString * const PostRESTKeyTitle = @"title"; @@ -74,8 +69,6 @@ NSString * const CrossPostMetaXPostOrigin = @"xpost_origin"; NSString * const CrossPostMetaCommentPrefix = @"comment-"; -static const NSInteger AvgWordsPerMinuteRead = 250; -static const NSInteger MinutesToReadThreshold = 2; static const NSUInteger ReaderPostTitleLength = 30; @implementation RemoteReaderPost @@ -95,12 +88,12 @@ - (instancetype)initWithDictionary:(NSDictionary *)dict; self.author = [self stringOrEmptyString:[authorDict stringForKey:PostRESTKeyNiceName]]; // typically the author's screen name self.authorAvatarURL = [self stringOrEmptyString:[authorDict stringForKey:PostRESTKeyAvatarURL]]; self.authorDisplayName = [[self stringOrEmptyString:[authorDict stringForKey:PostRESTKeyName]] stringByDecodingXMLCharacters]; // Typically the author's given name - self.authorEmail = [self authorEmailFromAuthorDictionary:authorDict]; + self.authorEmail = [RemoteReaderPost authorEmailFromAuthorDictionary:authorDict]; self.authorURL = [self stringOrEmptyString:[authorDict stringForKey:PostRESTKeyURL]]; self.siteIconURL = [self stringOrEmptyString:[dict stringForKeyPath:@"meta.data.site.icon.img"]]; - self.blogName = [self siteNameFromPostDictionary:dict]; + self.blogName = [RemoteReaderPost siteNameFromPostDictionary:dict]; self.blogDescription = [self siteDescriptionFromPostDictionary:dict]; - self.blogURL = [self siteURLFromPostDictionary:dict]; + self.blogURL = [RemoteReaderPost siteURLFromPostDictionary:dict]; self.commentCount = [discussionDict numberForKey:PostRESTKeyCommentCount]; self.commentsOpen = [[discussionDict numberForKey:PostRESTKeyCommentsOpen] boolValue]; self.content = [self postContentFromPostDictionary:dict]; @@ -109,12 +102,12 @@ - (instancetype)initWithDictionary:(NSDictionary *)dict; self.feedID = [dict numberForKey:PostRESTKeyFeedID]; self.feedItemID = [dict numberForKey:PostRESTKeyFeedItemID]; self.globalID = [self stringOrEmptyString:[dict stringForKey:PostRESTKeyGlobalID]]; - self.isBlogAtomic = [self siteIsAtomicFromPostDictionary:dict]; - self.isBlogPrivate = [self siteIsPrivateFromPostDictionary:dict]; + self.isBlogAtomic = [RemoteReaderPost siteIsAtomicFromPostDictionary:dict]; + self.isBlogPrivate = [RemoteReaderPost siteIsPrivateFromPostDictionary:dict]; self.isFollowing = [[dict numberForKey:PostRESTKeyIsFollowing] boolValue]; self.isLiked = [[dict numberForKey:PostRESTKeyILike] boolValue]; self.isReblogged = [[dict numberForKey:PostRESTKeyIsReblogged] boolValue]; - self.isWPCom = [self isWPComFromPostDictionary:dict]; + self.isWPCom = [RemoteReaderPost isWPComFromPostDictionary:dict]; self.likeCount = [dict numberForKey:PostRESTKeyLikeCount]; self.permalink = [self stringOrEmptyString:[dict stringForKey:PostRESTKeyURL]]; self.postID = [dict numberForKey:PostRESTKeyID]; @@ -125,7 +118,7 @@ - (instancetype)initWithDictionary:(NSDictionary *)dict; self.sortRank = @(self.sortDate.timeIntervalSinceReferenceDate); self.status = [self stringOrEmptyString:[dict stringForKey:PostRESTKeyStatus]]; self.summary = [self postSummaryFromPostDictionary:dict orPostContent:self.content]; - self.tags = [self tagsFromPostDictionary:dict]; + self.tags = [RemoteReaderPost tagsFromPostDictionary:dict]; self.isSharingEnabled = [[dict numberForKey:PostRESTKeySharingEnabled] boolValue]; self.isLikesEnabled = [[dict numberForKey:PostRESTKeyLikesEnabled] boolValue]; self.organizationID = [dict numberForKeyPath:PostRESTKeyOrganizationID] ?: @0; @@ -157,7 +150,7 @@ - (instancetype)initWithDictionary:(NSDictionary *)dict; self.isExternal = [[dict numberForKey:PostRESTKeyIsExternal] boolValue]; self.isJetpack = [[dict numberForKey:PostRESTKeyIsJetpack] boolValue]; self.wordCount = [dict numberForKey:PostRESTKeyWordCount]; - self.readingTime = [self readingTimeForWordCount:self.wordCount]; + self.readingTime = [RemoteReaderPost readingTimeForWordCount:self.wordCount]; NSDictionary *railcar = [dict dictionaryForKey:PostRESTKeyRailcar]; if (railcar) { @@ -281,16 +274,6 @@ - (NSDictionary *)primaryAndSecondaryTagsFromPostDictionary:(NSDictionary *)dict }; } -- (NSNumber *)readingTimeForWordCount:(NSNumber *)wordCount -{ - NSInteger count = [wordCount integerValue]; - NSInteger minutesToRead = count / AvgWordsPerMinuteRead; - if (minutesToRead < MinutesToReadThreshold) { - return @(0); - } - return @(minutesToRead); -} - /** Composes discover attribution if needed. @@ -394,55 +377,6 @@ - (NSString *)sanitizeFeaturedImageString:(NSString *)img #pragma mark - Data sanitization methods -/** - The v1 API result is inconsistent in that it will return a 0 when there is no author email. - - @param dict The author dictionary. - @return The author's email address or an empty string. - */ -- (NSString *)authorEmailFromAuthorDictionary:(NSDictionary *)dict -{ - NSString *authorEmail = [dict stringForKey:PostRESTKeyEmail]; - - // if 0 or less than minimum email length. a@a.aa - if ([authorEmail isEqualToString:@"0"] || [authorEmail length] < 6) { - authorEmail = @""; - } - - return authorEmail; -} - -/** - Parse whether the post belongs to a wpcom blog. - - @param dict A dictionary representing a post object from the REST API - @return YES if the post belongs to a wpcom blog, else NO - */ -- (BOOL)isWPComFromPostDictionary:(NSDictionary *)dict -{ - BOOL isExternal = [[dict numberForKey:PostRESTKeyIsExternal] boolValue]; - BOOL isJetpack = [[dict numberForKey:PostRESTKeyIsJetpack] boolValue]; - - return !isJetpack && !isExternal; -} - -/** - Get the tags assigned to a post and return them as a comma separated string. - - @param dict A dictionary representing a post object from the REST API. - @return A comma separated list of tags, or an empty string if no tags are found. - */ -- (NSString *)tagsFromPostDictionary:(NSDictionary *)dict -{ - NSDictionary *tagsDict = [dict dictionaryForKey:PostRESTKeyTags]; - NSArray *tagsList = [NSArray arrayWithArray:[tagsDict allKeys]]; - NSString *tags = [tagsList componentsJoinedByString:@", "]; - if (tags == nil) { - tags = @""; - } - return tags; -} - /** Get the date the post should be sorted by. @@ -528,32 +462,6 @@ - (NSString *)suitableImageFromPostContent:(NSDictionary *)dict { return [self stringOrEmptyString:imageToDisplay]; } -/** - Get the name of the post's site. - - @param dict A dictionary representing a post object from the REST API. - @return The name of the post's site or an empty string. - */ -- (NSString *)siteNameFromPostDictionary:(NSDictionary *)dict -{ - // Blog Name - NSString *siteName = [self stringOrEmptyString:[dict stringForKey:PostRESTKeySiteName]]; - - // For some endpoints blogname is defined in meta - NSString *metaBlogName = [dict stringForKeyPath:@"meta.data.site.name"]; - if (metaBlogName != nil) { - siteName = metaBlogName; - } - - // Values set in editorial trumps the rest - NSString *editorialSiteName = [dict stringForKeyPath:@"editorial.blog_name"]; - if (editorialSiteName != nil) { - siteName = editorialSiteName; - } - - return [self makePlainText:siteName]; -} - /** Get the description of the post's site. @@ -566,24 +474,6 @@ - (NSString *)siteDescriptionFromPostDictionary:(NSDictionary *)dict return [self makePlainText:description]; } -/** - Retrives the post site's URL - - @param dict A dictionary representing a post object from the REST API. - @return The URL path of the post's site. - */ -- (NSString *)siteURLFromPostDictionary:(NSDictionary *)dict -{ - NSString *siteURL = [self stringOrEmptyString:[dict stringForKey:PostRESTKeySiteURL]]; - - NSString *metaSiteURL = [dict stringForKeyPath:@"meta.data.site.URL"]; - if (metaSiteURL != nil) { - siteURL = metaSiteURL; - } - - return siteURL; -} - /** Retrives the post content from results dictionary @@ -623,31 +513,6 @@ - (NSString *)postSummaryFromPostDictionary:(NSDictionary *)dict orPostContent:( return summary; } -- (BOOL)siteIsAtomicFromPostDictionary:(NSDictionary *)dict -{ - NSNumber *isAtomic = [dict numberForKey:PostRESTKeySiteIsAtomic]; - - return [isAtomic boolValue]; -} - -/** - Retrives the privacy preference for the post's site. - - @param dict A dictionary representing a post object from the REST API. - @return YES if the site is private. - */ -- (BOOL)siteIsPrivateFromPostDictionary:(NSDictionary *)dict -{ - NSNumber *isPrivate = [dict numberForKey:PostRESTKeySiteIsPrivate]; - - NSNumber *metaIsPrivate = [dict numberForKeyPath:@"meta.data.site.is_private"]; - if (metaIsPrivate != nil) { - isPrivate = metaIsPrivate; - } - - return [isPrivate boolValue]; -} - - (NSArray *)slugsFromDiscoverPostTaxonomies:(NSArray *)discoverPostTaxonomies { return [discoverPostTaxonomies wp_map:^id(NSDictionary *dict) { diff --git a/WordPressKit/RemoteReaderPost.swift b/WordPressKit/RemoteReaderPost.swift index f3c99989..78fc789a 100644 --- a/WordPressKit/RemoteReaderPost.swift +++ b/WordPressKit/RemoteReaderPost.swift @@ -1,17 +1,118 @@ import Foundation -struct ReaderPostsEnvelope: Decodable { - var posts: [RemoteReaderPost] - var nextPageHandle: String? +public extension RemoteReaderPost { - private enum CodingKeys: String, CodingKey { - case posts - case nextPageHandle = "next_page_handle" + @objc(readingTimeForWordCount:) + class func readingTime(forWordCount wordCount: NSNumber) -> NSNumber { + let count = wordCount.intValue + let minutesToRead = count / avgWordsPerMinuteRead + if minutesToRead < minutesToReadThreshold { + return 0 + } + return minutesToRead as NSNumber } - public init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - let postDictionary = try container.decode([String: Any].self, forKey: .posts) - posts = [RemoteReaderPost(dictionary: postDictionary)] + /// The v1 API result is inconsistent in that it will return a 0 when there is no author email. + /// + /// - Parameter dict The author dictionary. + /// - Returns The author's email address or an empty string. + @objc(authorEmailFromAuthorDictionary:) + class func authorEmail(fromAuthorDictionary dict: NSDictionary) -> String { + if let authorEmail = dict.string(forKey: postRESTKeyEmail), + authorEmail.count >= minimalEmailLength { + return authorEmail + } + + return "" + } + + /// Parse whether the post belongs to a wpcom blog. + /// + /// - Parameter dict A dictionary representing a post object from the REST API + /// - Returns YES if the post belongs to a wpcom blog, else NO + @objc(isWPComFromPostDictionary:) + class func isWPCom(fromPostDictionary dict: NSDictionary) -> Bool { + let isExternal = dict.number(forKey: postRESTKeyIsExternal)?.boolValue ?? false + let isJetpack = dict.number(forKey: postRESTKeyIsJetpack)?.boolValue ?? false + return !isJetpack && !isExternal + } + + /// Get the tags assigned to a post and return them as a comma separated string. + /// + /// - Parameter dict A dictionary representing a post object from the REST API. + /// - Returns A comma separated list of tags, or an empty string if no tags are found. + @objc(tagsFromPostDictionary:) + class func tags(fromPostDictionary dict: NSDictionary) -> String { + if let tagsDict = dict[postRESTKeyTags] as? [String: Any] { + return tagsDict.keys.joined(separator: ", ") + } + + return "" + } + + /// Get the name of the post's site. + /// + /// - Parameter dict A dictionary representing a post object from the REST API. + /// - Returns The name of the post's site or an empty string. + @objc(siteNameFromPostDictionary:) + class func siteName(fromPostDictionary dict: NSDictionary) -> String { + // Blog Name + var siteName = dict.string(forKey: postRESTKeySiteName) ?? "" + + // For some endpoints blogname is defined in meta + if let metaBlogName = dict.string(forKeyPath: "meta.data.site.name") { + siteName = metaBlogName + } + + // Values set in editorial trumps the rest + if let editorialSiteName = dict.string(forKeyPath: "editorial.blog_name") { + siteName = editorialSiteName + } + + return (siteName as NSString).summarized() } + + /// Retrives the post site's URL + /// + /// - Parameter dict A dictionary representing a post object from the REST API. + /// - Returns The URL path of the post's site. + @objc(siteURLFromPostDictionary:) + class func siteURL(romPostDictionary dict: NSDictionary) -> String { + dict.string(forKeyPath: "meta.data.site.URL") + ?? dict.string(forKey: postRESTKeySiteURL) + ?? "" + } + + @objc(siteIsAtomicFromPostDictionary:) + class func siteIsAtomic(fromPostDictionary dict: NSDictionary) -> Bool { + dict.number(forKey: postRESTKeySiteIsAtomic)?.boolValue ?? false + } + + + /// Retrives the privacy preference for the post's site. + /// + /// - Parameter dict A dictionary representing a post object from the REST API. + /// - Returns YES if the site is private. + @objc(siteIsPrivateFromPostDictionary:) + class func siteIsPrivate(fromPostDictionary dict: NSDictionary) -> Bool { + dict.number(forKeyPath: "meta.data.site.is_private")?.boolValue + ?? dict.number(forKey: postRESTKeySiteIsPrivate)?.boolValue + ?? false + } + } + +private let postRESTKeyEmail = "email" +private let postRESTKeyIsExternal = "is_external" +private let postRESTKeyIsJetpack = "is_jetpack" +private let postRESTKeyTags = "tags" +private let postRESTKeySiteName = "site_name" +private let postRESTKeySiteURL = "site_URL" +private let postRESTKeySiteIsAtomic = "site_is_atomic" +private let postRESTKeySiteIsPrivate = "site_is_private" + +// The minimum email length: a@a.aa +private let minimalEmailLength = 6 + +private let avgWordsPerMinuteRead = 250 +private let minutesToReadThreshold = 2 diff --git a/WordPressKitTests/RemoteReaderPostTests.m b/WordPressKitTests/RemoteReaderPostTests.m index 50a3c751..7f8411fe 100644 --- a/WordPressKitTests/RemoteReaderPostTests.m +++ b/WordPressKitTests/RemoteReaderPostTests.m @@ -9,17 +9,10 @@ @interface RemoteReaderPost () - (RemoteReaderPost *)formatPostDictionary:(NSDictionary *)dict; -- (BOOL)siteIsAtomicFromPostDictionary:(NSDictionary *)dict; -- (BOOL)siteIsPrivateFromPostDictionary:(NSDictionary *)dict; -- (NSString *)siteURLFromPostDictionary:(NSDictionary *)dict; -- (NSString *)siteNameFromPostDictionary:(NSDictionary *)dict; - (NSString *)featuredImageFromPostDictionary:(NSDictionary *)dict; - (NSDate *)sortDateFromPostDictionary:(NSDictionary *)dict; -- (BOOL)isWPComFromPostDictionary:(NSDictionary *)dict; -- (NSString *)authorEmailFromAuthorDictionary:(NSDictionary *)dict; - (NSString *)sanitizeFeaturedImageString:(NSString *)img; - (NSDictionary *)primaryAndSecondaryTagsFromPostDictionary:(NSDictionary *)dict; -- (NSNumber *)readingTimeForWordCount:(NSNumber *)wordCount; - (NSString *)removeInlineStyles:(NSString *)string; - (NSString *)removeForbiddenTags:(NSString *)string; - (NSString *)postTitleFromPostDictionary:(NSDictionary *)dict; @@ -107,74 +100,66 @@ - (void)testSummaryIsPlainText { - (void)testSiteIsAtomic { NSString *key = @"site_is_atomic"; - RemoteReaderPost *remoteReaderPost = [RemoteReaderPost alloc]; - NSDictionary *dict = @{key: @"1"}; - BOOL isAtomic = [remoteReaderPost siteIsAtomicFromPostDictionary:dict]; + BOOL isAtomic = [RemoteReaderPost siteIsAtomicFromPostDictionary:dict]; XCTAssertTrue(isAtomic, @"Site should be atomic."); dict = @{key: @"0"}; - isAtomic = [remoteReaderPost siteIsAtomicFromPostDictionary:dict]; + isAtomic = [RemoteReaderPost siteIsAtomicFromPostDictionary:dict]; XCTAssertFalse(isAtomic, @"Site should not be atomic."); dict = @{}; - isAtomic = [remoteReaderPost siteIsAtomicFromPostDictionary:dict]; + isAtomic = [RemoteReaderPost siteIsAtomicFromPostDictionary:dict]; XCTAssertFalse(isAtomic, @"Site should not be atomic."); } - (void)testSiteIsPrivate { - RemoteReaderPost *remoteReaderPost = [RemoteReaderPost alloc]; - NSDictionary *dict = @{@"site_is_private": @"1"}; - BOOL isPrivate = [remoteReaderPost siteIsPrivateFromPostDictionary:dict]; + BOOL isPrivate = [RemoteReaderPost siteIsPrivateFromPostDictionary:dict]; XCTAssertTrue(isPrivate, @"Site should be private."); dict = @{@"site_is_private": @"0"}; - isPrivate = [remoteReaderPost siteIsPrivateFromPostDictionary:dict]; + isPrivate = [RemoteReaderPost siteIsPrivateFromPostDictionary:dict]; XCTAssertFalse(isPrivate, @"Site should not be private."); dict = [self metaDictionaryWithKey:@"is_private" value:@"1"]; - isPrivate = [remoteReaderPost siteIsPrivateFromPostDictionary:dict]; + isPrivate = [RemoteReaderPost siteIsPrivateFromPostDictionary:dict]; XCTAssertTrue(isPrivate, @"Meta site should be private."); dict = [self metaDictionaryWithKey:@"is_private" value:@"0"]; - isPrivate = [remoteReaderPost siteIsPrivateFromPostDictionary:dict]; + isPrivate = [RemoteReaderPost siteIsPrivateFromPostDictionary:dict]; XCTAssertFalse(isPrivate, @"Meta site should not be private."); } - (void)testSiteURLFromDictionary { - RemoteReaderPost *remoteReaderPost = [RemoteReaderPost alloc]; - NSString *site = @"http://site.com"; NSDictionary *dict = @{@"site_URL": site}; - NSString *siteURL = [remoteReaderPost siteURLFromPostDictionary:dict]; + NSString *siteURL = [RemoteReaderPost siteURLFromPostDictionary:dict]; XCTAssertEqual(siteURL, site, @"The returned site did not match what was expected."); dict = [self metaDictionaryWithKey:@"URL" value:site]; - siteURL = [remoteReaderPost siteURLFromPostDictionary:dict]; + siteURL = [RemoteReaderPost siteURLFromPostDictionary:dict]; XCTAssertEqual(siteURL, site, @"The returned site did not match what was expected."); } - (void)testSiteNameFromDictionary { - RemoteReaderPost *remoteReaderPost = [RemoteReaderPost alloc]; - NSString *name = @"foo"; NSDictionary *dict = @{@"site_name": name}; - NSString *siteName = [remoteReaderPost siteNameFromPostDictionary:dict]; + NSString *siteName = [RemoteReaderPost siteNameFromPostDictionary:dict]; XCTAssertEqualObjects(siteName, name, @"The returned site name did not match what was expected."); dict = [self metaDictionaryWithKey:@"name" value:name]; - siteName = [remoteReaderPost siteNameFromPostDictionary:dict]; + siteName = [RemoteReaderPost siteNameFromPostDictionary:dict]; XCTAssertEqualObjects(siteName, name, @"The returned site name did not match what was expected."); dict = [self editorialDictionaryWithKey:@"blog_name" value:name]; - siteName = [remoteReaderPost siteNameFromPostDictionary:dict]; + siteName = [RemoteReaderPost siteNameFromPostDictionary:dict]; XCTAssertEqualObjects(siteName, name, @"The returned site name did not match what was expected."); // Make sure editorial trumps other content. NSMutableDictionary *mDict = [dict mutableCopy]; [mDict setObject:@"bar" forKey:@"site_name"]; - siteName = [remoteReaderPost siteNameFromPostDictionary:dict]; + siteName = [RemoteReaderPost siteNameFromPostDictionary:dict]; XCTAssertEqualObjects(siteName, name, @"The returned site name did not match what was expected."); } @@ -236,31 +221,27 @@ - (void)testSortDateFromDictionary { } - (void)testIsWPComFromDictionary { - RemoteReaderPost *remoteReaderPost = [RemoteReaderPost alloc]; - NSString *jsonStrFalse = @"{\"is_external\": false}"; NSDictionary *dict = [NSJSONSerialization JSONObjectWithData:[jsonStrFalse dataUsingEncoding:NSUTF8StringEncoding] options:0 error:nil]; - BOOL isWPCom = [remoteReaderPost isWPComFromPostDictionary:dict]; + BOOL isWPCom = [RemoteReaderPost isWPComFromPostDictionary:dict]; XCTAssertTrue(isWPCom, @"A blog that is not external should be wpcom"); NSString *jsonStrTrue = @"{\"is_external\": true}"; dict = [NSJSONSerialization JSONObjectWithData:[jsonStrTrue dataUsingEncoding:NSUTF8StringEncoding] options:0 error:nil]; - isWPCom = [remoteReaderPost isWPComFromPostDictionary:dict]; + isWPCom = [RemoteReaderPost isWPComFromPostDictionary:dict]; XCTAssertFalse(isWPCom, @"A blog that is external should not be wpcom"); } - (void)testAuthorEmailFromDictionary { - RemoteReaderPost *remoteReaderPost = [RemoteReaderPost alloc]; - NSString *emailStr = @"a@a.aa"; NSDictionary *dict = @{@"email": emailStr}; - NSString *str = [remoteReaderPost authorEmailFromAuthorDictionary:dict]; + NSString *str = [RemoteReaderPost authorEmailFromAuthorDictionary:dict]; XCTAssertEqual(emailStr, str, @"The email returned did not match."); emailStr = @"0"; dict = @{@"email": emailStr}; - str = [remoteReaderPost authorEmailFromAuthorDictionary:dict]; + str = [RemoteReaderPost authorEmailFromAuthorDictionary:dict]; XCTAssertTrue([str length] == 0, @"If the value of email is 0, an empty string should be returned."); } @@ -337,22 +318,20 @@ - (void)testEditorialTagsFromDictionary - (void)testReadingTimeFromDictionary { - RemoteReaderPost *remoteReaderPost = [RemoteReaderPost alloc]; - NSNumber *readingTime; - readingTime = [remoteReaderPost readingTimeForWordCount:@0]; + readingTime = [RemoteReaderPost readingTimeForWordCount:@0]; XCTAssertTrue([readingTime integerValue] == 0, @"Zero wordcount should return zero reading time."); - readingTime = [remoteReaderPost readingTimeForWordCount:@250]; + readingTime = [RemoteReaderPost readingTimeForWordCount:@250]; XCTAssertTrue([readingTime integerValue] == 0, @"Brief word count should return zero reading time."); - readingTime = [remoteReaderPost readingTimeForWordCount:@500]; + readingTime = [RemoteReaderPost readingTimeForWordCount:@500]; XCTAssertTrue([readingTime integerValue] == 2, @"500 words should take about 2 minutes to read"); - readingTime = [remoteReaderPost readingTimeForWordCount:@700]; + readingTime = [RemoteReaderPost readingTimeForWordCount:@700]; XCTAssertTrue([readingTime integerValue] == 2, @"700 words should take about 2 minutes to read."); - readingTime = [remoteReaderPost readingTimeForWordCount:@1000]; + readingTime = [RemoteReaderPost readingTimeForWordCount:@1000]; XCTAssertTrue([readingTime integerValue] == 4, @"1000 words should take about 4 minutes to read"); } diff --git a/WordPressKitTests/RemoteReaderPostTests.swift b/WordPressKitTests/RemoteReaderPostTests.swift new file mode 100644 index 00000000..24b28551 --- /dev/null +++ b/WordPressKitTests/RemoteReaderPostTests.swift @@ -0,0 +1,57 @@ +import XCTest + +@testable import WordPressKit + +class NewRemoteReaderPostTests: XCTestCase { + + func testParsingEmptyTags() throws { + // REST API returns an empty _array_ if there is no tags associated with the post. + let jsonString = """ + { + "tags": [] + } + """ + let json = try XCTUnwrap(JSONSerialization.jsonObject(with: jsonString.data(using: .utf8)!) as? NSDictionary) + XCTAssertEqual(RemoteReaderPost.tags(fromPostDictionary: json), "") + } + + func testParsingTags() throws { + let jsonString = """ + { + "tags": { + "another-random-art-tag": { + "ID": 1, + "name": "another-random-art-tag", + "slug": "another-random-art-tag", + "description": "", + "post_count": 1, + "meta": { + "links": { + } + }, + "display_name": "another-random-art-tag" + }, + "random-art-tag": { + "ID": 2, + "name": "random-art-tag", + "slug": "random-art-tag", + "description": "", + "post_count": 1, + "meta": { + "links": { + } + }, + "display_name": "random-art-tag" + } + } + } + """ + let json = try XCTUnwrap(JSONSerialization.jsonObject(with: jsonString.data(using: .utf8)!) as? NSDictionary) + let tags = RemoteReaderPost.tags(fromPostDictionary: json) + XCTAssertTrue( + tags == "random-art-tag, another-random-art-tag" + || tags == "another-random-art-tag, random-art-tag" + ) + } + +}