diff --git a/Frameworks/RSParser/Feeds/FeedParserError.swift b/Frameworks/RSParser/Feeds/FeedParserError.swift index c1edd8900..434c42b03 100644 --- a/Frameworks/RSParser/Feeds/FeedParserError.swift +++ b/Frameworks/RSParser/Feeds/FeedParserError.swift @@ -14,7 +14,9 @@ public struct FeedParserError: Error { case rssChannelNotFound case rssItemsNotFound - + case jsonFeedVersionNotFound + case jsonFeedItemsNotFound + case jsonFeedTitleNotFound } public let errorType: FeedParserErrorType diff --git a/Frameworks/RSParser/Feeds/JSON/JSONFeedParser.swift b/Frameworks/RSParser/Feeds/JSON/JSONFeedParser.swift new file mode 100644 index 000000000..15b11ecc6 --- /dev/null +++ b/Frameworks/RSParser/Feeds/JSON/JSONFeedParser.swift @@ -0,0 +1,166 @@ +// +// JSONFeedParser.swift +// RSParser +// +// Created by Brent Simmons on 6/25/17. +// Copyright © 2017 Ranchero Software, LLC. All rights reserved. +// + +import Foundation + +// See https://jsonfeed.org/version/1 + +public struct JSONFeedParser { + + public static func parse(parserData: ParserData) throws -> ParsedFeed? { + + do { + let parsedObject = try JSONSerialization.jsonObject(with: parserData.data) + + guard let version = parsedObject["version"] as? String, version.hasPrefix("https://jsonfeed.org/version/") else { + throw FeedParserError(.jsonFeedVersionNotFound) + } + guard let itemsArray = parsedObject["items"] as? JSONArray else { + throw FeedParserError(.jsonFeedItemsNotFound) + } + guard let title = parsedObject["title"] as? String else { + throw FeedParserError(.jsonFeedTitleNotFound) + } + + let homePageURL = parsedObject["home_page_url"] as? String + let feedURL = parsedObject["feed_url"] ?? parserData.url + let feedDescription = parsedObject["description"] as? String + let nextURL = parsedObject["next_url"] as? String + let iconURL = parsedObject["icon_url"] as? String + let faviconURL = parsedObject["favicon_url"] as? String + let authors = parseAuthors(parsedObject) + let expired = parsedObject["expired"] as? Bool ?? false + let hubs = parseHubs(parsedObject) + + let items = parseItems(itemsArray) + + return ParsedFeed(type: .jsonFeed, title: title, homePageURL: homePageURL, feedURL: feedURL, feedDescription: feedDescription, nextURL: nextURL, iconURL: iconURL, faviconURL: faviconURL, authors: authors, expired: expired, hubs: hubs, items: items) + + } + catch { throw error } + } +} + +private extension JSONFeedParser { + + func parseAuthors(_ dictionary: JSONDictionary) -> [ParsedAuthor]? { + + guard let authorDictionary = dictionary["author"] as? JSONDictionary else { + return nil + } + + let name = authorDictionary["name"] + let url = authorDictionary["url"] + let avatar = authorDictionary["avatar"] + if name == nil && url == nil && avatar == nil { + return nil + } + let parsedAuthor = ParsedAuthor(name: name, url: url, avatarURL: avatar, emailAddress: nil) + return [parsedAuthor] + } + + func parseHubs(_ dictionary: JSONDictionary) -> [ParsedHub]? { + + guard let hubsArray = dictionary["hubs"] as? JSONArray else { + return nil + } + + let hubs = hubsArray.flatMap { (oneHubDictionary) -> ParsedHub? in + guard let oneHubURL = oneHubDictionary["url"], let oneHubType = oneHubDictionary["type"] else { + return nil + } + return ParsedHub(type: oneHubType, url: oneHubURL) + } + return hubs.isEmpty ? nil : hubs + } + + func parseItems(_ itemsArray: JSONArray) -> [ParsedItem] { + + return itemsArray.flatMap { (oneItemDictionary) -> ParsedItem? in + return parseItem(oneItemDictionary) + } + } + + func parseItem(_ itemDictionary: JSONDictionary) -> ParsedItem? { + + guard let uniqueID = parseUniqueID(itemDictionary) else { + return nil + } + + let contentHTML = itemDictionary["content_html"] as? String + let contentText = itemDictionary["content_text"] as? String + if contentHTML == nil && contentText == nil { + return nil + } + + let url = itemDictionary["url"] as? String + let externalURL = itemDictionary["external_url"] as? String + let title = itemDictionary["title"] as? String + let summary = itemDictionary["summary"] as? String + let imageURL = itemDictionary["image"] as? String + let bannerImageURL = itemDictionary["banner_image"] as? String + + let datePublished = parseDate(itemDictionary["date_published"]) + let dateModified = parseDate(itemDictionary["date_modified"]) + + let authors = parseAuthors(itemDictionary) + let tags = itemDictionary["tags"] as? [String] + let attachments = parseAttachments(itemDictionary) + + return ParsedItem(uniqueID: uniqueID, url: url, externalURL: externalURL, title: title, contentHTML: contentHTML, contentText: contentText, summary: summary, imageURL: imageURL, bannerImageURL: bannerImageURL, datePublished: datePublished, dateModified: dateModified, authors: authors, tags: tags, attachments: attachments) + } + + func parseUniqueID(_ itemDictionary: JSONDictionary) -> String? { + + if let uniqueID = itemDictionary["id"] as? String { + return uniqueID // Spec says it must be a string + } + // Spec also says that if it’s a number, even though that’s incorrect, it should be coerced to a string. + if let uniqueID = itemDictionary["id"] as? Int { + return "\(uniqueID)" + } + if let uniqueID = itemDictionary["id"] as? Double { + return "\(uniqueID)" + } + return nil + } + + func parseDate(_ dateString: String?) -> Date? { + + guard let dateString = dateString, !dateString.isEmpty else { + return nil + } + return RSDateWithString(dateString) + } + + func parseAttachments(_ itemDictionary: JSONDictionary) -> [ParsedAttachment]? { + + guard let attachmentsArray = itemDictionary["attachments"] as? JSONArray else { + return nil + } + return attachmentsArray.flatMap { (oneAttachmentObject) -> ParsedAttachment? in + return parseAttachment(oneAttachmentObject) + } + } + + func parseAttachment(_ attachmentObject: JSONDictionary) -> ParsedAttachment? { + + guard let url = attachmentObject["url"] as? String else { + return nil + } + guard let mimeType = attachmentObject["mime_type"] as? String else { + return nil + } + + let title = attachmentObject["title"] as? String + let sizeInBytes = attachmentObject["size_in_bytes"] as? Int + let durationInSeconds = attachmentObject["duration_in_seconds"] as? Int + + return ParsedAttachment(url: url, mimeType: mimeType, title: title, sizeInBytes: sizeInBytes, durationInSeconds: durationInSeconds) + } +} diff --git a/Frameworks/RSParser/Feeds/JSON/RSSInJSONParser.swift b/Frameworks/RSParser/Feeds/JSON/RSSInJSONParser.swift index ad5731be8..1f09cb565 100644 --- a/Frameworks/RSParser/Feeds/JSON/RSSInJSONParser.swift +++ b/Frameworks/RSParser/Feeds/JSON/RSSInJSONParser.swift @@ -104,7 +104,7 @@ private extension RSSInJSONParser { if let externalURL = externalURL { s += externalURL } - if let authorEmailAddress = authorEmailAddress { + if let authorEmailAddress = authors?.first?.emailAddress { s += authorEmailAddress } if let oneAttachmentURL = attachments?.first?.url { diff --git a/Frameworks/RSParser/RSParser.xcodeproj/project.pbxproj b/Frameworks/RSParser/RSParser.xcodeproj/project.pbxproj index c34d80191..982a4187c 100644 --- a/Frameworks/RSParser/RSParser.xcodeproj/project.pbxproj +++ b/Frameworks/RSParser/RSParser.xcodeproj/project.pbxproj @@ -50,6 +50,7 @@ 84469D381EFF2645004A6B28 /* RSSInJSONParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84469D371EFF2645004A6B28 /* RSSInJSONParser.swift */; }; 84469D401EFF29A9004A6B28 /* FeedParserError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84469D3F1EFF29A9004A6B28 /* FeedParserError.swift */; }; 84469D421EFF2B2D004A6B28 /* JSONTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84469D411EFF2B2D004A6B28 /* JSONTypes.swift */; }; + 84469D441F002CEF004A6B28 /* JSONFeedParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84469D431F002CEF004A6B28 /* JSONFeedParser.swift */; }; 84D81BDC1EFA28E700652332 /* RSParser.h in Headers */ = {isa = PBXBuildFile; fileRef = 84D81BDA1EFA28E700652332 /* RSParser.h */; settings = {ATTRIBUTES = (Public, ); }; }; 84D81BDE1EFA2B7D00652332 /* ParsedFeed.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84D81BDD1EFA2B7D00652332 /* ParsedFeed.swift */; }; 84D81BE01EFA2BAE00652332 /* FeedType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84D81BDF1EFA2BAE00652332 /* FeedType.swift */; }; @@ -115,6 +116,7 @@ 84469D371EFF2645004A6B28 /* RSSInJSONParser.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = RSSInJSONParser.swift; path = Feeds/JSON/RSSInJSONParser.swift; sourceTree = ""; }; 84469D3F1EFF29A9004A6B28 /* FeedParserError.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = FeedParserError.swift; path = Feeds/FeedParserError.swift; sourceTree = ""; }; 84469D411EFF2B2D004A6B28 /* JSONTypes.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = JSONTypes.swift; path = Feeds/JSON/JSONTypes.swift; sourceTree = ""; }; + 84469D431F002CEF004A6B28 /* JSONFeedParser.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = JSONFeedParser.swift; path = Feeds/JSON/JSONFeedParser.swift; sourceTree = ""; }; 84D81BD91EFA28E700652332 /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 84D81BDA1EFA28E700652332 /* RSParser.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = RSParser.h; sourceTree = ""; }; 84D81BDD1EFA2B7D00652332 /* ParsedFeed.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = ParsedFeed.swift; path = Feeds/ParsedFeed.swift; sourceTree = ""; }; @@ -244,6 +246,7 @@ children = ( 84469D411EFF2B2D004A6B28 /* JSONTypes.swift */, 84469D371EFF2645004A6B28 /* RSSInJSONParser.swift */, + 84469D431F002CEF004A6B28 /* JSONFeedParser.swift */, ); name = JSON; sourceTree = ""; @@ -439,6 +442,7 @@ 84D81BE61EFA2DFB00652332 /* ParsedAttachment.swift in Sources */, 84D81BDE1EFA2B7D00652332 /* ParsedFeed.swift in Sources */, 84D81BE81EFA2E6700652332 /* ParsedHub.swift in Sources */, + 84469D441F002CEF004A6B28 /* JSONFeedParser.swift in Sources */, 84D81BE01EFA2BAE00652332 /* FeedType.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0;