Make a mess of things. Article and ArticleStatus are now immutable structs.

This commit is contained in:
Brent Simmons 2017-09-04 17:10:02 -07:00
parent fb121f8a8c
commit b0cb01a68e
13 changed files with 560 additions and 119 deletions

View File

@ -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<String>?
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<Author>?
public let tags: Set<String>?
public let attachments: Set<Attachment>?
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<String>?, 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<Author>?, tags: Set<String>?, attachments: Set<Attachment>?, 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)
}
}
}

View File

@ -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
}
}

View File

@ -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
}
}

View File

@ -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<Article>()
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 whats 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 didnt 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 theyre 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<Article> {
func fetchArticlesWithWhereClause(_ database: FMDatabase, whereClause: String, parameters: [AnyObject], withLimits: Bool) -> Set<Article> {
// Dont fetch articles that shouldnt 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<Article> {
func fetchArticlesForFeedID(_ feedID: String, withLimits: Bool, database: FMDatabase) -> Set<Article> {
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<String>) -> Set<Article> {
@ -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 thats 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<Article>()
var articlesWithAttachmentChanges = Set<Article>()
var articlesWithAuthorChanges = Set<Article>()
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<Article>()
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<Article>()
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<ParsedItem>, _ feed: Feed) -> Set<Article> {
// These Articles dont 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<Article>, _ database: FMDatabase) {
saveRelatedObjects(articles, database)
let databaseDictionaries = articles.map { $0.databaseDictionary() }
insertRows(databaseDictionaries, insertType: .orIgnore, in: database)
}
func saveRelatedObjects(_ articles: Set<Article>, _ 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 theres no existing status or Article, then its 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: -

View File

@ -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)
}
}

View File

@ -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
}

View File

@ -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<Article> {
@ -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

View File

@ -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<String>? = 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<T>(_ keyPath: KeyPath<Article,T?>) -> Set<Article> {
return Set(filter{ $0[keyPath: keyPath] == nil })
}
func articleIDs() -> Set<String> {
return Set<String>(map { $0.databaseID })
@ -84,7 +189,7 @@ extension Set where Element == Article {
func missingStatuses() -> Set<Article> {
return withNilProperty(\Article.status)
return Set<Article>(self.filter { $0.status == nil })
}
func statuses() -> Set<ArticleStatus> {
@ -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
}
}
}

View File

@ -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<Attachment>? {
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? {

View File

@ -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<Author>? {
guard let parsedAuthors = parsedAuthors else {
return nil
}
let authors = parsedAuthors.flatMap { Author(parsedAuthor: $0) }
return authors.isEmpty ? nil : Set(authors)
}
}
extension Author: DatabaseObject {

View File

@ -20,3 +20,12 @@ extension String: DatabaseObject {
}
}
}
func tagSetWithParsedTags(_ parsedTags: [String]?) -> Set<String>? {
guard let parsedTags = parsedTags, !parsedTags.isEmpty else {
return nil
}
return Set(parsedTags)
}

View File

@ -75,6 +75,36 @@ final class StatusesTable: DatabaseTable {
createAndSaveStatusesForArticleIDs(articleIDsNeedingStatus, database)
}
func fetchStatusesForArticleIDs(_ articleIDs: Set<String>, _ database: FMDatabase) -> [String: ArticleStatus] {
// Does not create statuses. Checks cache first, then database only if needed.
var d = [String: ArticleStatus]()
var articleIDsMissingCachedStatus = Set<String>()
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<String>, _ statusKey: String, _ flag: Bool, _ database: FMDatabase) {

View File

@ -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
}