From d33d8a0330ecd590e48ef86618230ecd91de066a Mon Sep 17 00:00:00 2001 From: Brent Simmons Date: Sat, 2 Sep 2017 14:19:42 -0700 Subject: [PATCH] Make progress toward saving/updating articles. --- Frameworks/Data/DatabaseID.swift | 10 +- Frameworks/Database/ArticlesTable.swift | 38 ++++- .../Database.xcodeproj/project.pbxproj | 4 + .../Extensions/Article+Database.swift | 9 + .../Extensions/ParsedItem+Database.swift | 40 +++++ Frameworks/Database/StatusesTable.swift | 155 +----------------- Frameworks/RSParser/Feeds/ParsedItem.swift | 8 +- ToDo.ooutline | Bin 2640 -> 2719 bytes 8 files changed, 108 insertions(+), 156 deletions(-) create mode 100644 Frameworks/Database/Extensions/ParsedItem+Database.swift diff --git a/Frameworks/Data/DatabaseID.swift b/Frameworks/Data/DatabaseID.swift index 83249e74d..a7558e184 100644 --- a/Frameworks/Data/DatabaseID.swift +++ b/Frameworks/Data/DatabaseID.swift @@ -12,7 +12,15 @@ import Foundation // * It’s fast // * Collisions aren’t going to happen with feed data +private var databaseIDCache = [String: String]() + public func databaseIDWithString(_ s: String) -> String { - return (s as NSString).rs_md5Hash() + if let identifier = databaseIDCache[s] { + return identifier + } + + let identifier = (s as NSString).rs_md5Hash() + databaseIDCache[s] = identifier + return identifier } diff --git a/Frameworks/Database/ArticlesTable.swift b/Frameworks/Database/ArticlesTable.swift index e9a2a950d..83afb289c 100644 --- a/Frameworks/Database/ArticlesTable.swift +++ b/Frameworks/Database/ArticlesTable.swift @@ -17,7 +17,7 @@ final class ArticlesTable: DatabaseTable { let name: String private weak var account: Account? private let queue: RSDatabaseQueue - private let statusesTable: StatusesTable + private let statusesTable = StatusesTable() private let authorsLookupTable: DatabaseLookupTable private let attachmentsLookupTable: DatabaseLookupTable private let tagsLookupTable: DatabaseLookupTable @@ -32,7 +32,6 @@ final class ArticlesTable: DatabaseTable { self.account = account self.queue = queue - self.statusesTable = StatusesTable(name: DatabaseTableName.statuses) let authorsTable = AuthorsTable(name: DatabaseTableName.authors) self.authorsLookupTable = DatabaseLookupTable(name: DatabaseTableName.authorsLookup, objectIDKey: DatabaseKey.articleID, relatedObjectIDKey: DatabaseKey.authorID, relatedTable: authorsTable, relationshipName: RelationshipName.authors) @@ -80,8 +79,26 @@ final class ArticlesTable: DatabaseTable { // MARK: Updating func update(_ feed: Feed, _ parsedFeed: ParsedFeed, _ completion: @escaping RSVoidCompletionBlock) { - - // TODO + + 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 + self.updateArticles(articles.dictionary(), parsedFeed.itemsDictionary(with: feed), feed, completion) + } } // MARK: Unread Counts @@ -133,7 +150,7 @@ final class ArticlesTable: DatabaseTable { } } -// MARK: - +// MARK: - Private private extension ArticlesTable { @@ -232,6 +249,14 @@ private extension ArticlesTable { } return articlesWithResultSet(resultSet, database) } + + // MARK: Saving/Updating + + func updateArticles(_ articlesDictionary: [String: Article], _ parsedItemsDictionary: [String: ParsedItem], _ feed: Feed, _ completion: @escaping RSVoidCompletionBlock) { + + + } + } // MARK: - @@ -276,3 +301,6 @@ private struct ArticleCache { } } + + + diff --git a/Frameworks/Database/Database.xcodeproj/project.pbxproj b/Frameworks/Database/Database.xcodeproj/project.pbxproj index 0b191d7e0..500b63d64 100644 --- a/Frameworks/Database/Database.xcodeproj/project.pbxproj +++ b/Frameworks/Database/Database.xcodeproj/project.pbxproj @@ -11,6 +11,7 @@ 843CB9961F34174100EE6581 /* Author+Database.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84F20F901F1810DD00D8E682 /* Author+Database.swift */; }; 844BEE411F0AB3AB004AB7CD /* Database.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 844BEE371F0AB3AA004AB7CD /* Database.framework */; }; 844BEE461F0AB3AB004AB7CD /* DatabaseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 844BEE451F0AB3AB004AB7CD /* DatabaseTests.swift */; }; + 844ECFC91F5B4F0E005E405A /* ParsedItem+Database.swift in Sources */ = {isa = PBXBuildFile; fileRef = 844ECFC81F5B4F0E005E405A /* ParsedItem+Database.swift */; }; 845580671F0AEBCD003CCFA1 /* Constants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 845580661F0AEBCD003CCFA1 /* Constants.swift */; }; 845580721F0AEE49003CCFA1 /* AccountInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 845580711F0AEE49003CCFA1 /* AccountInfo.swift */; }; 845580761F0AF670003CCFA1 /* Article+Database.swift in Sources */ = {isa = PBXBuildFile; fileRef = 845580751F0AF670003CCFA1 /* Article+Database.swift */; }; @@ -118,6 +119,7 @@ 844BEE401F0AB3AB004AB7CD /* DatabaseTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = DatabaseTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 844BEE451F0AB3AB004AB7CD /* DatabaseTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatabaseTests.swift; sourceTree = ""; }; 844BEE471F0AB3AB004AB7CD /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 844ECFC81F5B4F0E005E405A /* ParsedItem+Database.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = "ParsedItem+Database.swift"; path = "Extensions/ParsedItem+Database.swift"; sourceTree = ""; }; 845580661F0AEBCD003CCFA1 /* Constants.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Constants.swift; sourceTree = ""; }; 845580711F0AEE49003CCFA1 /* AccountInfo.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AccountInfo.swift; sourceTree = ""; }; 845580751F0AF670003CCFA1 /* Article+Database.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = "Article+Database.swift"; path = "Extensions/Article+Database.swift"; sourceTree = ""; }; @@ -221,6 +223,7 @@ 84F20F901F1810DD00D8E682 /* Author+Database.swift */, 8455807B1F0C0DBD003CCFA1 /* Attachment+Database.swift */, 84D0DEA01F4A429800073503 /* String+Database.swift */, + 844ECFC81F5B4F0E005E405A /* ParsedItem+Database.swift */, 845580711F0AEE49003CCFA1 /* AccountInfo.swift */, ); name = Extensions; @@ -484,6 +487,7 @@ 84F20F8F1F180D8700D8E682 /* AuthorsTable.swift in Sources */, 84E156EC1F0AB80E00F8CC05 /* ArticlesTable.swift in Sources */, 84E156EE1F0AB81400F8CC05 /* StatusesTable.swift in Sources */, + 844ECFC91F5B4F0E005E405A /* ParsedItem+Database.swift in Sources */, 845580721F0AEE49003CCFA1 /* AccountInfo.swift in Sources */, 84E156EA1F0AB80500F8CC05 /* Database.swift in Sources */, ); diff --git a/Frameworks/Database/Extensions/Article+Database.swift b/Frameworks/Database/Extensions/Article+Database.swift index 109ced656..355638fee 100644 --- a/Frameworks/Database/Extensions/Article+Database.swift +++ b/Frameworks/Database/Extensions/Article+Database.swift @@ -91,4 +91,13 @@ extension Set where Element == Article { return Set(self.flatMap { $0.status }) } + + func dictionary() -> [String: Article] { + + var d = [String: Article]() + for article in self { + d[article.articleID] = article + } + return d + } } diff --git a/Frameworks/Database/Extensions/ParsedItem+Database.swift b/Frameworks/Database/Extensions/ParsedItem+Database.swift new file mode 100644 index 000000000..45dec0d1b --- /dev/null +++ b/Frameworks/Database/Extensions/ParsedItem+Database.swift @@ -0,0 +1,40 @@ +// +// ParsedItem+Database.swift +// Database +// +// Created by Brent Simmons on 9/2/17. +// Copyright © 2017 Ranchero Software. All rights reserved. +// + +import Foundation +import RSParser + +extension ParsedItem { + + private func databaseIdentifierWithFeed(_ feed: Feed) -> String { + + if let identifier = syncServiceID { + return identifier + } + + // Must be, and is, the same calculation as in Article.init. + return databaseIDWithString("\(feed.feedID) \(uniqueID)") + } +} + +extension ParsedFeed { + + func itemsDictionary(with feed: Feed) -> [String: ParsedItem] { + + var d = [String: ParsedItem]() + + for parsedItem in parsedItems { + let identifier = identifierForParsedItem(parsedItem, feed) + d[identifier] = parsedItem + } + + return d + } +} + + diff --git a/Frameworks/Database/StatusesTable.swift b/Frameworks/Database/StatusesTable.swift index 8737bbc81..08ce8d902 100644 --- a/Frameworks/Database/StatusesTable.swift +++ b/Frameworks/Database/StatusesTable.swift @@ -17,30 +17,9 @@ import Data final class StatusesTable: DatabaseTable { - let name: String - let databaseIDKey = DatabaseKey.articleID + let name = DatabaseTableName.statuses private let cache = DatabaseObjectCache() - init(name: String) { - - self.name = name - } - - // Mark: DatabaseTable Methods - - func objectWithRow(_ row: FMResultSet) -> DatabaseObject? { - - if let status = statusWithRow(row) { - return status as DatabaseObject - } - return nil - } - - func save(_ objects: [DatabaseObject], in database: FMDatabase) { - - // TODO - } - // MARK: Fetching func statusWithRow(_ row: FMResultSet) -> ArticleStatus? { @@ -82,68 +61,6 @@ final class StatusesTable: DatabaseTable { updateRowsWithValue(NSNumber(value: flag), valueKey: statusKey, whereKey: DatabaseKey.articleID, matches: Array(articleIDs), database: database) } - - // MARK: Updating - - - -// func attachStatuses(_ articles: Set
, _ database: FMDatabase) { -// -// // Look in cache first. -// attachCachedStatuses(articles) -// let articlesNeedingStatuses = articlesMissingStatuses(articles) -// if articlesNeedingStatuses.isEmpty { -// return -// } -// -// // Fetch from database. -// fetchAndCacheStatusesForArticles(articlesNeedingStatuses, database) -// attachCachedStatuses(articlesNeedingStatuses) -// -// // Create new statuses, and cache and save them in the database. -// // It shouldn’t happen that an Article in the database has no corresponding ArticleStatus, -// // but the case should be handled anyway. -// -// let articlesNeedingStatusesCreated = articlesMissingStatuses(articlesNeedingStatuses) -// if articlesNeedingStatusesCreated.isEmpty { -// return -// } -// createAndSaveStatusesForArticles(articlesNeedingStatusesCreated, database) -// -// assertNoMissingStatuses(articles) -// } - - -// func ensureStatusesForParsedArticles(_ parsedArticles: [ParsedItem], _ callback: @escaping RSVoidCompletionBlock) { -// -// // 1. Check cache for statuses -// // 2. Fetch statuses not found in cache -// // 3. Create, save, and cache statuses not found in database -// -// var articleIDs = Set(parsedArticles.map { $0.articleID }) -// articleIDs = articleIDsMissingStatuses(articleIDs) -// if articleIDs.isEmpty { -// callback() -// return -// } -// -// queue.fetch { (database: FMDatabase!) -> Void in -// -// let statuses = self.fetchStatusesForArticleIDs(articleIDs, database: database) -// -// DispatchQueue.main.async { -// -// self.cache.addObjectsNotCached(Array(statuses)) -// -// let newArticleIDs = self.articleIDsMissingStatuses(articleIDs) -// if !newArticleIDs.isEmpty { -// self.createAndSaveStatusesForArticleIDs(newArticleIDs) -// } -// -// callback() -// } -// } -// } } private extension StatusesTable { @@ -157,46 +74,6 @@ private extension StatusesTable { } } - // MARK: Fetching - -// func fetchAndCacheStatusesForArticles(_ articles: Set
, _ database: FMDatabase) { -// -// fetchAndCacheStatusesForArticleIDs(articles.articleIDs(), database) -// } -// -// func fetchAndCacheStatusesForArticleIDs(_ articleIDs: Set, _ database: FMDatabase) { -// -// if let statuses = fetchStatusesForArticleIDs(articleIDs, database) { -// cache.addObjectsNotCached(Array(statuses)) -// } -// } -// -// func fetchStatusesForArticleIDs(_ articleIDs: Set, _ database: FMDatabase) -> Set? { -// -// guard let resultSet = selectRowsWhere(key: DatabaseKey.articleID, inValues: Array(articleIDs), in: database) else { -// return nil -// } -// return articleStatusesWithResultSet(resultSet) -// } -// -// func articleStatusesWithResultSet(_ resultSet: FMResultSet) -> Set { -// -// return resultSet.mapToSet(articleStatusWithRow) -// } -// -// func articleStatusWithRow(_ row: FMResultSet) -> ArticleStatus? { -// -// guard let articleID = row.string(forColumn: DatabaseKey.articleID) else { -// return nil -// } -// if let cachedStatus = cache[articleID] { -// return cachedStatus -// } -// let status = ArticleStatus(articleID: articleID, row: row) -// cache[articleID] = status -// return status -// } - // MARK: Creating func saveStatuses(_ statuses: Set, _ database: FMDatabase) { @@ -205,6 +82,12 @@ private extension StatusesTable { insertRows(statusArray, insertType: .orIgnore, in: database) } + func cacheStatuses(_ statuses: [ArticleStatus]) { + + let databaseObjects = statuses.map { $0 as DatabaseObject } + cache.addObjectsNotCached(databaseObjects) + } + func createAndSaveStatusesForArticles(_ articles: Set
, _ database: FMDatabase) { let articleIDs = Set(articles.map { $0.articleID }) @@ -215,31 +98,9 @@ private extension StatusesTable { let now = Date() let statuses = articleIDs.map { ArticleStatus(articleID: $0, dateArrived: now) } - let databaseObjects = statuses.map { $0 as DatabaseObject } - cache.addObjectsNotCached(databaseObjects) + cacheStatuses(statuses) saveStatuses(Set(statuses), database) } - // MARK: Utilities - -// func articleIDsMissingCachedStatuses(_ articleIDs: Set) -> Set { -// -// return Set(articleIDs.filter { !cache.objectWithIDIsCached($0) }) -// } -// -// func articlesMissingStatuses(_ articles: Set
) -> Set
{ -// -// return articles.withNilProperty(\Article.status) -// } } - -//extension ParsedItem { -// -// var articleID: String { -// get { -// return "\(feedURL) \(uniqueID)" //Must be same as Article.articleID -// } -// } -//} - diff --git a/Frameworks/RSParser/Feeds/ParsedItem.swift b/Frameworks/RSParser/Feeds/ParsedItem.swift index 8af508ac9..a96ca6539 100644 --- a/Frameworks/RSParser/Feeds/ParsedItem.swift +++ b/Frameworks/RSParser/Feeds/ParsedItem.swift @@ -10,7 +10,8 @@ import Foundation public struct ParsedItem: Hashable { - public let uniqueID: String + public let syncServiceID: String? //Nil when not syncing + public let uniqueID: String //RSS guid, for instance; may be calculated public let feedURL: String public let url: String? public let externalURL: String? @@ -27,8 +28,9 @@ public struct ParsedItem: Hashable { public let attachments: [ParsedAttachment]? public let hashValue: Int - init(uniqueID: String, feedURL: String, url: String?, externalURL: String?, title: String?, contentHTML: String?, contentText: String?, summary: String?, imageURL: String?, bannerImageURL: String?, datePublished: Date?, dateModified: Date?, authors: [ParsedAuthor]?, tags: [String]?, attachments: [ParsedAttachment]?) { + init(syncServiceID: String?, uniqueID: String, feedURL: String, url: String?, externalURL: String?, title: String?, contentHTML: String?, contentText: String?, summary: String?, imageURL: String?, bannerImageURL: String?, datePublished: Date?, dateModified: Date?, authors: [ParsedAuthor]?, tags: [String]?, attachments: [ParsedAttachment]?) { + self.syncServiceID = syncServiceID self.uniqueID = uniqueID self.feedURL = feedURL self.url = url @@ -44,7 +46,7 @@ public struct ParsedItem: Hashable { self.authors = authors self.tags = tags self.attachments = attachments - self.hashValue = uniqueID.hashValue + self.hashValue = articleID.hashValue } public static func ==(lhs: ParsedItem, rhs: ParsedItem) -> Bool { diff --git a/ToDo.ooutline b/ToDo.ooutline index 7ecc05bb716ec1e7c098aef9074451f1775aa37a..cdd6810350cfef1e84c98a8172211ac5adf5d38b 100644 GIT binary patch delta 2666 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 ILiP9b7g%xOq5uE@