From b0cb01a68e748ac0dce29922a8daaa3f727f3ca7 Mon Sep 17 00:00:00 2001 From: Brent Simmons Date: Mon, 4 Sep 2017 17:10:02 -0700 Subject: [PATCH] Make a mess of things. Article and ArticleStatus are now immutable structs. --- Frameworks/Data/Article.swift | 73 +++-- Frameworks/Data/ArticleStatus.swift | 54 ++-- Frameworks/Data/Attachment.swift | 2 +- Frameworks/Database/ArticlesTable.swift | 295 ++++++++++++++++-- Frameworks/Database/AttachmentsTable.swift | 11 +- Frameworks/Database/AuthorsTable.swift | 4 +- Frameworks/Database/Database.swift | 15 +- .../Extensions/Article+Database.swift | 149 ++++++++- .../Extensions/Attachment+Database.swift | 19 ++ .../Database/Extensions/Author+Database.swift | 16 + .../Database/Extensions/String+Database.swift | 9 + Frameworks/Database/StatusesTable.swift | 30 ++ .../RSDatabase/DatabaseLookupTable.swift | 2 +- 13 files changed, 560 insertions(+), 119 deletions(-) diff --git a/Frameworks/Data/Article.swift b/Frameworks/Data/Article.swift index 73655c0f5..cf2c7cffd 100644 --- a/Frameworks/Data/Article.swift +++ b/Frameworks/Data/Article.swift @@ -8,40 +8,31 @@ import Foundation -public final class Article: Hashable { +public struct Article: Hashable { - weak var account: Account? - - public let articleID: String // Unique database ID + public let articleID: String // Unique database ID (possibly sync service ID) + public let accountID: String public let feedID: String // Likely a URL, but not necessarily public let uniqueID: String // Unique per feed (RSS guid, for example) - public var title: String? - public var contentHTML: String? - public var contentText: String? - public var url: String? - public var externalURL: String? - public var summary: String? - public var imageURL: String? - public var bannerImageURL: String? - public var datePublished: Date? - public var dateModified: Date? - public var authors: [Author]? - public var tags: Set? - public var attachments: [Attachment]? - public var accountInfo: [String: Any]? //If account needs to store more data - - public var status: ArticleStatus? + public let title: String? + public let contentHTML: String? + public let contentText: String? + public let url: String? + public let externalURL: String? + public let summary: String? + public let imageURL: String? + public let bannerImageURL: String? + public let datePublished: Date? + public let dateModified: Date? + public let authors: Set? + public let tags: Set? + public let attachments: Set? public let hashValue: Int - public var feed: Feed? { - get { - return account?.existingFeed(with: feedID) - } - } - public init(account: Account, articleID: String?, feedID: String, uniqueID: String, title: String?, contentHTML: String?, contentText: String?, url: String?, externalURL: String?, summary: String?, imageURL: String?, bannerImageURL: String?, datePublished: Date?, dateModified: Date?, authors: [Author]?, tags: Set?, attachments: [Attachment]?, accountInfo: AccountInfo?) { + public init(accountID: String, articleID: String?, feedID: String, uniqueID: String, title: String?, contentHTML: String?, contentText: String?, url: String?, externalURL: String?, summary: String?, imageURL: String?, bannerImageURL: String?, datePublished: Date?, dateModified: Date?, authors: Set?, tags: Set?, attachments: Set?, accountInfo: AccountInfo?) { - self.account = account + self.accountID = accountID self.feedID = feedID self.uniqueID = uniqueID self.title = title @@ -66,12 +57,12 @@ public final class Article: Hashable { self.articleID = databaseIDWithString("\(feedID) \(uniqueID)") } - self.hashValue = account.hashValue ^ self.articleID.hashValue + self.hashValue = accountID.hashValue ^ self.articleID.hashValue } public class func ==(lhs: Article, rhs: Article) -> Bool { - return lhs === rhs + return lhs.hashValue == rhs.hashValue && lhs.articleID == rhs.articleID && lhs.accountID == rhs.accountID && lhs.feedID == rhs.feedID && lhs.uniqueID == rhs.uniqueID && lhs.title == rhs.title && lhs.contentHTML == rhs.contentHTML && lhs.url == rhs.url && lhs.externalURL == rhs.externalURL && lhs.summary == rhs.summary && lhs.imageURL == rhs.imageURL && lhs.bannerImageURL == rhs.bannerImageURL && lhs.datePublished == rhs.datePublished && lhs.authors == rhs.authors && lhs.tags == rhs.tags && lhs.attachments == rhs.attachments } } @@ -82,4 +73,28 @@ public extension Article { return (datePublished ?? dateModified) ?? status?.dateArrived } } + + // MARK: Main-thread only accessors. + + public var account: Account? { + get { + assert(Thread.isMainThread, "article.account is main-thread-only.") + return Account.account(with: accountID) + } + } + + public var feed: Feed? { + get { + assert(Thread.isMainThread, "article.feed is main-thread-only.") + return account?.existingFeed(with: feedID) + } + } + + public var status: ArticleStatus? { + get { + assert(Thread.isMainThread, "article.status is main-thread-only.") + return account?.status(with: articleID) + } + } } + diff --git a/Frameworks/Data/ArticleStatus.swift b/Frameworks/Data/ArticleStatus.swift index 907958999..0644caf44 100644 --- a/Frameworks/Data/ArticleStatus.swift +++ b/Frameworks/Data/ArticleStatus.swift @@ -15,14 +15,14 @@ public enum ArticleStatusKey: String { case userDeleted = "userDeleted" } -public final class ArticleStatus: Hashable { +public struct ArticleStatus: Hashable { - public var read = false - public var starred = false - public var userDeleted = false - public var dateArrived: Date public let articleID: String - public var accountInfo: AccountInfo? + public let read = false + public let starred = false + public let userDeleted = false + public let dateArrived: Date + public let accountInfo: AccountInfo? public let hashValue: Int public init(articleID: String, read: Bool, starred: Bool, userDeleted: Bool, dateArrived: Date, accountInfo: AccountInfo?) { @@ -59,28 +59,28 @@ public final class ArticleStatus: Hashable { return false } - public func setBoolStatus(_ status: Bool, forKey key: String) { - - if let articleStatusKey = ArticleStatusKey(rawValue: key) { - switch articleStatusKey { - case .read: - read = status - case .starred: - starred = status - case .userDeleted: - userDeleted = status - } - } - else { - if accountInfo == nil { - accountInfo = AccountInfo() - } - accountInfo![key] = status - } - } - +// public func setBoolStatus(_ status: Bool, forKey key: String) { +// +// if let articleStatusKey = ArticleStatusKey(rawValue: key) { +// switch articleStatusKey { +// case .read: +// read = status +// case .starred: +// starred = status +// case .userDeleted: +// userDeleted = status +// } +// } +// else { +// if accountInfo == nil { +// accountInfo = AccountInfo() +// } +// accountInfo![key] = status +// } +// } + public class func ==(lhs: ArticleStatus, rhs: ArticleStatus) -> Bool { - return lhs.articleID == rhs.articleID && lhs.dateArrived == rhs.dateArrived && lhs.read == rhs.read && lhs.starred == rhs.starred + return lhs.hashValue == rhs.hashValue && lhs.articleID == rhs.articleID && lhs.dateArrived == rhs.dateArrived && lhs.read == rhs.read && lhs.starred == rhs.starred && lhs.userDeleted == rhs.userDeleted && lhs.accountInfo == rhs.accountInfo } } diff --git a/Frameworks/Data/Attachment.swift b/Frameworks/Data/Attachment.swift index 61008e313..5bdebdc6b 100644 --- a/Frameworks/Data/Attachment.swift +++ b/Frameworks/Data/Attachment.swift @@ -47,6 +47,6 @@ public struct Attachment: Hashable { public static func ==(lhs: Attachment, rhs: Attachment) -> Bool { - return lhs.sizeInBytes == rhs.sizeInBytes && lhs.url == rhs.url && lhs.mimeType == rhs.mimeType && lhs.title == rhs.title && lhs.durationInSeconds == rhs.durationInSeconds + return lhs.hashValue == rhs.hashValue && lhs.attachmentID == rhs.attachmentID } } diff --git a/Frameworks/Database/ArticlesTable.swift b/Frameworks/Database/ArticlesTable.swift index 0815a8885..479b87ddf 100644 --- a/Frameworks/Database/ArticlesTable.swift +++ b/Frameworks/Database/ArticlesTable.swift @@ -25,6 +25,7 @@ final class ArticlesTable: DatabaseTable { // TODO: update articleCutoffDate as time passes and based on user preferences. private var articleCutoffDate = NSDate.rs_dateWithNumberOfDays(inThePast: 3 * 31)! + private var maximumArticleCutoffDate = NSDate.rs_dateWithNumberOfDays(inThePast: 4 * 31)! init(name: String, account: Account, queue: RSDatabaseQueue) { @@ -50,19 +51,19 @@ final class ArticlesTable: DatabaseTable { var articles = Set
() queue.fetchSync { (database: FMDatabase!) -> Void in - articles = self.fetchArticlesForFeedID(feedID, database: database) + articles = self.fetchArticlesForFeedID(feedID, withLimits: true, database: database) } return articleCache.uniquedArticles(articles) } - func fetchArticlesAsync(_ feed: Feed, _ resultBlock: @escaping ArticleResultBlock) { + func fetchArticlesAsync(_ feed: Feed, withLimits: Bool, _ resultBlock: @escaping ArticleResultBlock) { let feedID = feed.feedID queue.fetch { (database: FMDatabase!) -> Void in - let fetchedArticles = self.fetchArticlesForFeedID(feedID, database: database) + let fetchedArticles = self.fetchArticlesForFeedID(feedID, withLimits: withLimits, database: database) DispatchQueue.main.async { let articles = self.articleCache.uniquedArticles(fetchedArticles) @@ -81,22 +82,18 @@ final class ArticlesTable: DatabaseTable { func update(_ feed: Feed, _ parsedFeed: ParsedFeed, _ completion: @escaping RSVoidCompletionBlock) { if parsedFeed.items.isEmpty { - - // Once upon a time in an early version of NetNewsWire there was a bug with this issue. - // The design, at the time, was that NetNewsWire would always show only what’s currently in a feed — - // no more and no less. - // This meant that if a feed had zero items, then all the articles for that feed would be deleted. - // There were two problems with that: - // 1. People didn’t expect articles to just disappear like that. - // 2. Technorati (R.I.P.) had a bug where some of its feeds, at seemingly random times, would have zero items. - // So this hit people. THEY WERE NOT HAPPY. - // These days we just ignore empty feeds. Who cares if they’re empty. It just means less work the app has to do. - completion() return } - fetchArticlesAsync(feed) { (articles) in + // 1. Ensure statuses for all the parsedItems. + // 2. Fetch all articles for the feed. + // 3. For each parsedItem: + // - if userDeleted || (!starred && status.dateArrived < cutoff), then ignore + // - if matches existing article, then update database with changes between the two + // - if new, create article and save in database + + fetchArticlesAsync(feed, withLimits: false) { (articles) in self.updateArticles(articles.dictionary(), parsedFeed.itemsDictionary(with: feed), feed, completion) } } @@ -195,13 +192,13 @@ private extension ArticlesTable { return articles } - func fetchArticlesWithWhereClause(_ database: FMDatabase, whereClause: String, parameters: [AnyObject]) -> Set
{ + func fetchArticlesWithWhereClause(_ database: FMDatabase, whereClause: String, parameters: [AnyObject], withLimits: Bool) -> Set
{ // Don’t fetch articles that shouldn’t appear in the UI. The rules: // * Must not be deleted. // * Must be either 1) starred or 2) dateArrived must be newer than cutoff date. - let sql = "select * from articles natural join statuses where \(whereClause) and userDeleted=0 and (starred=1 or dateArrived>?);" + let sql = withLimits ? "select * from articles natural join statuses where \(whereClause) and userDeleted=0 and (starred=1 or dateArrived>?);" : "select * from articles natural join statuses where \(whereClause);" return articlesWithSQL(sql, parameters + [articleCutoffDate as AnyObject], database) } @@ -216,9 +213,9 @@ private extension ArticlesTable { return numberWithSQLAndParameters(sql, [feedID, articleCutoffDate], in: database) } - func fetchArticlesForFeedID(_ feedID: String, database: FMDatabase) -> Set
{ + func fetchArticlesForFeedID(_ feedID: String, withLimits: Bool, database: FMDatabase) -> Set
{ - return fetchArticlesWithWhereClause(database, whereClause: "articles.feedID = ?", parameters: [feedID as AnyObject]) + return fetchArticlesWithWhereClause(database, whereClause: "articles.feedID = ?", parameters: [feedID as AnyObject], withLimits: withLimits) } func fetchUnreadArticles(_ feedIDs: Set) -> Set
{ @@ -236,7 +233,7 @@ private extension ArticlesTable { let parameters = feedIDs.map { $0 as AnyObject } let placeholders = NSString.rs_SQLValueList(withPlaceholders: UInt(feedIDs.count))! let whereClause = "feedID in \(placeholders) and read=0" - articles = self.fetchArticlesWithWhereClause(database, whereClause: whereClause, parameters: parameters) + articles = self.fetchArticlesWithWhereClause(database, whereClause: whereClause, parameters: parameters, withLimits: true) } return articleCache.uniquedArticles(articles) @@ -254,17 +251,269 @@ private extension ArticlesTable { func updateArticles(_ articlesDictionary: [String: Article], _ parsedItemsDictionary: [String: ParsedItem], _ feed: Feed, _ completion: @escaping RSVoidCompletionBlock) { - let parsedItemArticleIDs = Set(parsedItemsDictionary.keys) + // 1. Fetch statuses for parsedItems. + // 2. Filter out parsedItems where userDeleted==1 or (arrival date > 4 months and not starred). + // (Under no user setting do we retain articles older with an arrival date > 4 months.) + // 3. Find parsedItems with no status and no matching article: save them as entirely new articles. + // 4. Compare remaining parsedItems with articles, and update database with any changes. - queue.update { (database) in + assert(Thread.isMainThread) - self.statusesTable.ensureStatusesForArticleIDs(parsedItemArticleIDs, database) + queue.fetch { (database) in + let parsedItemArticleIDs = Set(parsedItemsDictionary.keys) + let fetchedStatuses = self.statusesTable.fetchStatusesForArticleIDs(parsedItemArticleIDs, database) + + DispatchQueue.main.async { + + // #2. Drop any parsedItems that can be ignored. + // If that’s all of them, then great — nothing to do. + let filteredParsedItems = self.filterParsedItems(parsedItemsDictionary, fetchedStatuses) + if filteredParsedItems.isEmpty { + completion() + return + } + + // #3. Save entirely new parsedItems. + let newParsedItems = self.findNewParsedItems(parsedItemsDictionary, fetchedStatuses, articlesDictionary) + if !newParsedItems.isEmpty { + self.saveNewParsedItems(newParsedItems, feed) + } + + // #4. Update existing parsedItems. + let parsedItemsToUpdate = self.findExistingParsedItems(parsedItemsDictionary, fetchedStatuses, articlesDictionary) + if !parsedItemsToUpdate.isEmpty { + self.updateParsedItems(parsedItemsToUpdate, articlesDictionary, feed) + } + + completion() + } } + } + func updateParsedItems(_ parsedItems: [String: ParsedItem], _ articles: [String: Article], _ feed: Feed) { + + assert(Thread.isMainThread) + + updateRelatedObjects(_ parsedItems: [String: ParsedItem], _ articles: [String: Article]) } + func updateRelatedObjects(_ parsedItems: [String: ParsedItem], _ articles: [String: Article]) { + + // Update the in-memory Articles when needed. + // Save only when there are changes, which should be pretty infrequent. + + assert(Thread.isMainThread) + + var articlesWithTagChanges = Set
() + var articlesWithAttachmentChanges = Set
() + var articlesWithAuthorChanges = Set
() + + for (articleID, parsedItem) in parsedItems { + + guard let article = articles[articleID] else { + continue + } + + if article.updateTagsWithParsedTags(parsedItem.tags) { + articlesWithTagChanges.insert(article) + } + if article.updateAttachmentsWithParsedAttachments(parsedItem.attachments) { + articlesWithAttachmentChanges.insert(article) + } + if article.updateAuthorsWithParsedAuthors(parsedItem.authors) { + articlesWithAuthorChanges.insert(article) + } + } + + if articlesWithTagChanges.isEmpty && articlesWithAttachmentChanges.isEmpty && articlesWithAuthorChanges.isEmpty { + // Should be pretty common. + return + } + + // We used detachedCopy because the Article objects being updated are main-thread objects. + + articlesWithTagChanges = Set(articlesWithTagChanges.map{ $0.detachedCopy() }) + articlesWithAttachmentChanges = Set(articlesWithAttachmentChanges.map{ $0.detachedCopy() }) + articlesWithAuthorChanges = Set(articlesWithAuthorChanges.map{ $0.detachedCopy() }) + + queue.update { (database) in + if !articlesWithTagChanges.isEmpty { + tagsLookupTable.saveRelatedObjects(for: articlesWithTagChanges.databaseObjects(), in: database) + } + if !articlesWithAttachmentChanges.isEmpty { + attachmentsLookupTable.saveRelatedObjects(for: articlesWithAttachmentChanges.databaseObjects(), in: database) + } + if !articlesWithAuthorChanges.isEmpty { + authorsLookupTable.saveRelatedObjects(for: articlesWithAuthorChanges.databaseObjects(), in: database) + } + } + } + + func updateRelatedAttachments(_ parsedItems: [String: ParsedItem], _ articles: [String: Article]) { + + var articlesWithChanges = Set
() + + for (articleID, parsedItem) in parsedItems { + guard let article = articles[articleID] else { + continue + } + if !parsedItemTagsMatchArticlesTag(parsedItem, article) { + articlesChanges.insert(article) + } + } + + if articlesWithChanges.isEmpty { + return + } + queue.update { (database) in + tagsLookupTable.saveRelatedObjects(for: articlesWithChanges.databaseObjects(), in: database) + } + + } + + func updateRelatedTags(_ parsedItems: [String: ParsedItem], _ articles: [String: Article]) { + + var articlesWithChanges = Set
() + + for (articleID, parsedItem) in parsedItems { + guard let article = articles[articleID] else { + continue + } + if !parsedItemTagsMatchArticlesTag(parsedItem, article) { + articlesChanges.insert(article) + } + } + + if articlesWithChanges.isEmpty { + return + } + queue.update { (database) in + tagsLookupTable.saveRelatedObjects(for: articlesWithChanges.databaseObjects(), in: database) + } + } + + func parsedItemTagsMatchArticlesTag(_ parsedItem: ParsedItem, _ article: Article) -> Bool { + + let parsedItemTags = parsedItem.tags + let articleTags = article.tags + + if parsedItemTags == nil && articleTags == nil { + return true + } + if parsedItemTags != nil && articleTags == nil { + return false + } + if parsedItemTags == nil && articleTags != nil { + return true + } + return Set(parsedItemTags!) == articleTags! + } + + func saveNewParsedItems(_ parsedItems: [String: ParsedItem], _ feed: Feed) { + + // These parsedItems have no existing status or Article. + + queue.update { (database) in + + let articleIDs = Set(parsedItems.keys) + self.statusesTable.ensureStatusesForArticleIDs(articleIDs, database) + + let articles = self.articlesWithParsedItems(Set(parsedItems.values), feed) + self.saveUncachedNewArticles(articles, database) + } + } + + func articlesWithParsedItems(_ parsedItems: Set, _ feed: Feed) -> Set
{ + + // These Articles don’t get cached. Background-queue only. + let feedID = feed.feedID + return Set(parsedItems.flatMap{ articleWithParsedItem($0, feedID) }) + } + + func articleWithParsedItem(_ parsedItem: ParsedItem, _ feedID: String) -> Article? { + + guard let account = account else { + assertionFailure("account is unexpectedly nil.") + return nil + } + + return Article(parsedItem: parsedItem, feedID: feedID, account: account) + } + + func saveUncachedNewArticles(_ articles: Set
, _ database: FMDatabase) { + + saveRelatedObjects(articles, database) + + let databaseDictionaries = articles.map { $0.databaseDictionary() } + insertRows(databaseDictionaries, insertType: .orIgnore, in: database) + } + + func saveRelatedObjects(_ articles: Set
, _ database: FMDatabase) { + + let databaseObjects = articles.databaseObjects() + + authorsLookupTable.saveRelatedObjects(for: databaseObjects, in: database) + attachmentsLookupTable.saveRelatedObjects(for: databaseObjects, in: database) + tagsLookupTable.saveRelatedObjects(for: databaseObjects, in: database) + } + + func statusIndicatesArticleIsIgnorable(_ status: ArticleStatus) -> Bool { + + // Ignorable articles: either userDeleted==1 or (not starred and arrival date > 4 months). + + if status.userDeleted { + return true + } + if status.starred { + return false + } + return status.dateArrived < maximumArticleCutoffDate + } + + func filterParsedItems(_ parsedItems: [String: ParsedItem], _ statuses: [String: ArticleStatus]) -> [String: ParsedItem] { + + // Drop parsedItems that we can ignore. + + assert(Thread.isMainThread) + + var d = [String: ParsedItem]() + + for (articleID, parsedItem) in parsedItems { + + if let status = statuses[articleID] { + if statusIndicatesArticleIsIgnorable(status) { + continue + } + } + d[articleID] = parsedItem + } + + return d + } + + func findNewParsedItems(_ parsedItems: [String: ParsedItem], _ statuses: [String: ArticleStatus], _ articles: [String: Article]) -> [String: ParsedItem] { + + // If there’s no existing status or Article, then it’s completely new. + + assert(Thread.isMainThread) + + var d = [String: ParsedItem]() + + for (articleID, parsedItem) in parsedItems { + if statuses[articleID] == nil && articles[articleID] == nil { + d[articleID] = parsedItem + } + } + + return d + } + + func findExistingParsedItems(_ parsedItems: [String: ParsedItem], _ statuses: [String: ArticleStatus], _ articles: [String: Article]) -> [String: ParsedItem] { + + return [String: ParsedItem]() //TODO + } } // MARK: - diff --git a/Frameworks/Database/AttachmentsTable.swift b/Frameworks/Database/AttachmentsTable.swift index eef37385d..80a879e0d 100644 --- a/Frameworks/Database/AttachmentsTable.swift +++ b/Frameworks/Database/AttachmentsTable.swift @@ -14,7 +14,6 @@ final class AttachmentsTable: DatabaseRelatedObjectsTable { let name: String let databaseIDKey = DatabaseKey.attachmentID - private let cache = DatabaseObjectCache() init(name: String) { @@ -43,14 +42,6 @@ private extension AttachmentsTable { guard let attachmentID = row.string(forColumn: DatabaseKey.attachmentID) else { return nil } - if let cachedAttachment = cache[attachmentID] as? Attachment { - return cachedAttachment - } - - guard let attachment = Attachment(attachmentID: attachmentID, row: row) else { - return nil - } - cache[attachmentID] = attachment as DatabaseObject - return attachment + return Attachment(attachmentID: attachmentID, row: row) } } diff --git a/Frameworks/Database/AuthorsTable.swift b/Frameworks/Database/AuthorsTable.swift index bb28fb140..69fe7e135 100644 --- a/Frameworks/Database/AuthorsTable.swift +++ b/Frameworks/Database/AuthorsTable.swift @@ -21,7 +21,6 @@ final class AuthorsTable: DatabaseRelatedObjectsTable { let name: String let databaseIDKey = DatabaseKey.authorID - private let cache = DatabaseObjectCache() init(name: String) { @@ -41,7 +40,6 @@ final class AuthorsTable: DatabaseRelatedObjectsTable { func save(_ objects: [DatabaseObject], in database: FMDatabase) { // TODO } - } private extension AuthorsTable { @@ -52,7 +50,7 @@ private extension AuthorsTable { return nil } - if let cachedAuthor = cache[authorID] as? Author { + if let cachedAuthor = Author.cachedAuthor[authorID] { return cachedAuthor } diff --git a/Frameworks/Database/Database.swift b/Frameworks/Database/Database.swift index 8fbbc10de..3ea01febd 100644 --- a/Frameworks/Database/Database.swift +++ b/Frameworks/Database/Database.swift @@ -50,7 +50,7 @@ public final class Database { public func fetchArticlesAsync(for feed: Feed, _ resultBlock: @escaping ArticleResultBlock) { - articlesTable.fetchArticlesAsync(feed, resultBlock) + articlesTable.fetchArticlesAsync(feed, withLimits: true, resultBlock) } public func fetchUnreadArticles(for folder: Folder) -> Set
{ @@ -70,19 +70,6 @@ public final class Database { public func update(feed: Feed, parsedFeed: ParsedFeed, completion: @escaping RSVoidCompletionBlock) { return articlesTable.update(feed, parsedFeed, completion) - -// if parsedFeed.items.isEmpty { -// completionHandler() -// return -// } -// -// let parsedArticlesDictionary = self.articlesDictionary(parsedFeed.items as NSSet) as! [String: ParsedItem] -// -// fetchArticlesForFeedAsync(feed) { (articles) -> Void in -// -// let articlesDictionary = self.articlesDictionary(articles as NSSet) as! [String: Article] -// self.updateArticles(articlesDictionary, parsedArticles: parsedArticlesDictionary, feed: feed, completionHandler: completionHandler) -// } } // MARK: - Status diff --git a/Frameworks/Database/Extensions/Article+Database.swift b/Frameworks/Database/Extensions/Article+Database.swift index 355638fee..57926d942 100644 --- a/Frameworks/Database/Extensions/Article+Database.swift +++ b/Frameworks/Database/Extensions/Article+Database.swift @@ -9,6 +9,7 @@ import Foundation import RSDatabase import Data +import RSParser extension Article { @@ -35,20 +36,129 @@ extension Article { let accountInfo: [String: Any]? = nil // TODO // authors, tags, and attachments are fetched from related tables, after init. - let authors: [Author]? = nil - let tags: Set? = nil - let attachments: [Attachment]? = nil - - self.init(account: account, articleID: articleID, feedID: feedID, uniqueID: uniqueID, title: title, contentHTML: contentHTML, contentText: contentText, url: url, externalURL: externalURL, summary: summary, imageURL: imageURL, bannerImageURL: bannerImageURL, datePublished: datePublished, dateModified: dateModified, authors: authors, tags: tags, attachments: attachments, accountInfo: accountInfo) + + self.init(account: account, articleID: articleID, feedID: feedID, uniqueID: uniqueID, title: title, contentHTML: contentHTML, contentText: contentText, url: url, externalURL: externalURL, summary: summary, imageURL: imageURL, bannerImageURL: bannerImageURL, datePublished: datePublished, dateModified: dateModified, authors: nil, tags: nil, attachments: nil, accountInfo: accountInfo) + } + + convenience init(parsedItem: ParsedItem, feedID: String, account: Account) { + + let authors = Author.authorsWithParsedAuthors(parsedItem.authors) + let attachments = Attachment.attachmentsWithParsedAttachments(parsedItem.attachments) + let tags = tagSetWithParsedTags(parsedItem.tags) + + self.init(account: account, articleID: parsedItem.syncServiceID, feedID: feedID, uniqueID: parsedItem.uniqueID, title: parsedItem.title, contentHTML: parsedItem.contentHTML, contentText: parsedItem.contentText, url: parsedItem.url, externalURL: parsedItem.externalURL, summary: parsedItem.summary, imageURL: parsedItem.imageURL, bannerImageURL: parsedItem.bannerImageURL, datePublished: parsedItem.datePublished, dateModified: parsedItem.dateModified, authors: authors, tags: tags, attachments: attachments, accountInfo: nil) } func databaseDictionary() -> NSDictionary { let d = NSMutableDictionary() + d[DatabaseKey.articleID] = articleID + d[DatabaseKey.feedID] = feedID + d[DatabaseKey.uniqueID] = uniqueID + + d.addOptionalString(title, DatabaseKey.title) + d.addOptionalString(contentHTML, DatabaseKey.contentHTML) + d.addOptionalString(url, DatabaseKey.url) + d.addOptionalString(externalURL, DatabaseKey.externalURL) + d.addOptionalString(summary, DatabaseKey.summary) + d.addOptionalString(imageURL, DatabaseKey.imageURL) + d.addOptionalString(bannerImageURL, DatabaseKey.bannerImageURL) + + d.addOptionalDate(datePublished, DatabaseKey.datePublished) + d.addOptionalDate(dateModified, DatabaseKey.dateModified) + + // TODO: accountInfo return d.copy() as! NSDictionary } + + // MARK: Updating with ParsedItem + + func updateTagsWithParsedTags(_ parsedTags: [String]?) -> Bool { + + // Return true if there's a change. + + let currentTags = tags + + if parsedTags == nil && currentTags == nil { + return false + } + if parsedTags != nil && currentTags == nil { + tags = Set(parsedItemTags!) + return true + } + if parsedTags == nil && currentTags != nil { + tags = nil + return true + } + let parsedTagSet = Set(parsedTags!) + if parsedTagSet == tags! { + return false + } + tags = parsedTagSet + return true + } + + func updateAttachmentsWithParsedAttachments(_ parsedAttachments: [ParsedAttachment]?) -> Bool { + + // Return true if there's a change. + + let currentAttachments = attachments + let updatedAttachments = Attachment.attachmentsWithParsedAttachments(parsedAttachments) + + if updatedAttachments == nil && currentAttachments == nil { + return false + } + if updatedAttachments != nil && currentAttachments == nil { + attachments = updatedAttachments + return true + } + if updatedAttachments == nil && currentAttachments != nil { + attachments = nil + return true + } + + guard let currentAttachments = currentAttachments, let updatedAttachments = updatedAttachments else { + assertionFailure("currentAttachments and updatedAttachments must both be non-nil.") + return false + } + if currentAttachments != updatedAttachments { + attachments = updatedAttachments + return true + } + return false + } + + func updateAuthorsWithParsedAuthors(_ parsedAuthors: [ParsedAuthor]?) -> Bool { + + // Return true if there's a change. + + let currentAuthors = authors + let updatedAuthors = Author.authorsWithParsedAuthors(parsedAuthors) + + if updatedAuthors == nil && currentAuthors == nil { + return false + } + if updatedAuthors != nil && currentAuthors == nil { + authors = updatedAuthors + return true + } + if updatedAuthors == nil && currentAuthors != nil { + authors = nil + return true + } + + guard let currentAuthors = currentAuthors, let updatedAuthors = updatedAuthors else { + assertionFailure("currentAuthors and updatedAuthors must both be non-nil.") + return false + } + if currentAuthors != updatedAuthors { + authors = updatedAuthors + return true + } + return false + } } extension Article: DatabaseObject { @@ -62,11 +172,6 @@ extension Article: DatabaseObject { extension Set where Element == Article { - func withNilProperty(_ keyPath: KeyPath) -> Set
{ - - return Set(filter{ $0[keyPath: keyPath] == nil }) - } - func articleIDs() -> Set { return Set(map { $0.databaseID }) @@ -84,7 +189,7 @@ extension Set where Element == Article { func missingStatuses() -> Set
{ - return withNilProperty(\Article.status) + return Set
(self.filter { $0.status == nil }) } func statuses() -> Set { @@ -100,4 +205,26 @@ extension Set where Element == Article { } return d } + + func databaseObjects() -> [DatabaseObject] { + + return self.map{ $0 as DatabaseObject } + } +} + +private extension NSMutableDictionary { + + func addOptionalString(_ value: String?, _ key: String) { + + if let value = value { + self[key] = value + } + } + + func addOptionalDate(_ date: Date?, _ key: String) { + + if let date = date { + self[key] = date as NSDate + } + } } diff --git a/Frameworks/Database/Extensions/Attachment+Database.swift b/Frameworks/Database/Extensions/Attachment+Database.swift index 3ef5b0d4d..796514d58 100644 --- a/Frameworks/Database/Extensions/Attachment+Database.swift +++ b/Frameworks/Database/Extensions/Attachment+Database.swift @@ -9,6 +9,7 @@ import Foundation import Data import RSDatabase +import RSParser extension Attachment { @@ -26,6 +27,24 @@ extension Attachment { self.init(attachmentID: attachmentID, url: url, mimeType: mimeType, title: title, sizeInBytes: sizeInBytes, durationInSeconds: durationInSeconds) } + init?(parsedAttachment: ParsedAttachment) { + + guard let url = parsedAttachment.url else { + return nil + } + + self.init(attachmentID: nil, url: url, mimeType: parsedAttachment.mimeType, title: parsedAttachment.title, sizeInBytes: parsedAttachment.sizeInBytes, durationInSeconds: parsedAttachment.durationInSeconds) + } + + static func attachmentsWithParsedAttachments(_ parsedAttachments: [ParsedAttachment]?) -> Set? { + + guard let parsedAttachments = parsedAttachments else { + return nil + } + + let attachments = parsedAttachments.flatMap{ Attachment(parsedAttachment: $0) } + return attachments.isEmpty ? nil : Set(attachments) + } } private func optionalIntForColumn(_ row: FMResultSet, _ columnName: String) -> Int? { diff --git a/Frameworks/Database/Extensions/Author+Database.swift b/Frameworks/Database/Extensions/Author+Database.swift index 51bc8eac9..7840002f0 100644 --- a/Frameworks/Database/Extensions/Author+Database.swift +++ b/Frameworks/Database/Extensions/Author+Database.swift @@ -9,6 +9,7 @@ import Foundation import Data import RSDatabase +import RSParser extension Author { @@ -21,6 +22,21 @@ extension Author { self.init(authorID: authorID, name: name, url: url, avatarURL: avatarURL, emailAddress: emailAddress) } + + init?(parsedAuthor: ParsedAuthor) { + + self.init(authorID: nil, name: parsedAuthor.name, url: parsedAuthor.url, avatarURL: parsedAuthor.avatarURL, emailAddress: parsedAuthor.emailAddress) + } + + static func authorsWithParsedAuthors(_ parsedAuthors: [ParsedAuthor]?) -> Set? { + + guard let parsedAuthors = parsedAuthors else { + return nil + } + + let authors = parsedAuthors.flatMap { Author(parsedAuthor: $0) } + return authors.isEmpty ? nil : Set(authors) + } } extension Author: DatabaseObject { diff --git a/Frameworks/Database/Extensions/String+Database.swift b/Frameworks/Database/Extensions/String+Database.swift index dcbf47493..ae5fc9d6c 100644 --- a/Frameworks/Database/Extensions/String+Database.swift +++ b/Frameworks/Database/Extensions/String+Database.swift @@ -20,3 +20,12 @@ extension String: DatabaseObject { } } } + +func tagSetWithParsedTags(_ parsedTags: [String]?) -> Set? { + + guard let parsedTags = parsedTags, !parsedTags.isEmpty else { + return nil + } + + return Set(parsedTags) +} diff --git a/Frameworks/Database/StatusesTable.swift b/Frameworks/Database/StatusesTable.swift index 2d37fd4a6..ca5243798 100644 --- a/Frameworks/Database/StatusesTable.swift +++ b/Frameworks/Database/StatusesTable.swift @@ -75,6 +75,36 @@ final class StatusesTable: DatabaseTable { createAndSaveStatusesForArticleIDs(articleIDsNeedingStatus, database) } + func fetchStatusesForArticleIDs(_ articleIDs: Set, _ database: FMDatabase) -> [String: ArticleStatus] { + + // Does not create statuses. Checks cache first, then database only if needed. + + var d = [String: ArticleStatus]() + var articleIDsMissingCachedStatus = Set() + + for articleID in articleIDs { + if let cachedStatus = cache[articleID] as? ArticleStatus { + d[articleID] = cachedStatus + } + else { + articleIDsMissingCachedStatus.insert(articleID) + } + } + + if articleIDsMissingCachedStatus.isEmpty { + return d + } + + fetchAndCacheStatusesForArticleIDs(articleIDsMissingCachedStatus, database) + for articleID in articleIDsMissingCachedStatus { + if let cachedStatus = cache[articleID] as? ArticleStatus { + d[articleID] = cachedStatus + } + } + + return d + } + // MARK: Marking func markArticleIDs(_ articleIDs: Set, _ statusKey: String, _ flag: Bool, _ database: FMDatabase) { diff --git a/Frameworks/RSDatabase/RSDatabase/DatabaseLookupTable.swift b/Frameworks/RSDatabase/RSDatabase/DatabaseLookupTable.swift index 11032a449..b0f01e579 100644 --- a/Frameworks/RSDatabase/RSDatabase/DatabaseLookupTable.swift +++ b/Frameworks/RSDatabase/RSDatabase/DatabaseLookupTable.swift @@ -126,7 +126,7 @@ private extension DatabaseLookupTable { let relatedObjectsToSave = uniqueArrayOfRelatedObjects(with: objectsNeedingUpdate) if relatedObjectsToSave.isEmpty { - assertionFailure("updateRelationships: expected related objects to save. This should be unreachable.") + assertionFailure("updateRelationships: expected relatedObjectsToSave would not be empty. This should be unreachable.") return }