NetNewsWire/Frameworks/Database/ArticlesTable.swift

489 lines
17 KiB
Swift
Raw Normal View History

//
// ArticlesTable.swift
// Evergreen
//
// Created by Brent Simmons on 5/9/16.
// Copyright © 2016 Ranchero Software, LLC. All rights reserved.
//
import Foundation
import RSCore
import RSDatabase
import RSParser
import Data
final class ArticlesTable: DatabaseTable {
let name: String
2017-09-05 17:53:45 +02:00
private let accountID: String
2017-08-27 00:37:15 +02:00
private let queue: RSDatabaseQueue
2017-09-05 17:53:45 +02:00
private let statusesTable: StatusesTable
private let authorsLookupTable: DatabaseLookupTable
private let attachmentsLookupTable: DatabaseLookupTable
private let tagsLookupTable: DatabaseLookupTable
2017-08-31 22:35:48 +02:00
// 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)!
2017-08-31 22:35:48 +02:00
2017-09-05 17:53:45 +02:00
init(name: String, accountID: String, queue: RSDatabaseQueue) {
self.name = name
2017-09-05 17:53:45 +02:00
self.accountID = accountID
2017-08-27 00:37:15 +02:00
self.queue = queue
self.statusesTable = StatusesTable(queue: queue)
2017-09-05 17:53:45 +02:00
let authorsTable = AuthorsTable(name: DatabaseTableName.authors)
self.authorsLookupTable = DatabaseLookupTable(name: DatabaseTableName.authorsLookup, objectIDKey: DatabaseKey.articleID, relatedObjectIDKey: DatabaseKey.authorID, relatedTable: authorsTable, relationshipName: RelationshipName.authors)
let tagsTable = TagsTable(name: DatabaseTableName.tags)
self.tagsLookupTable = DatabaseLookupTable(name: DatabaseTableName.tags, objectIDKey: DatabaseKey.articleID, relatedObjectIDKey: DatabaseKey.tagName, relatedTable: tagsTable, relationshipName: RelationshipName.tags)
let attachmentsTable = AttachmentsTable(name: DatabaseTableName.attachments)
self.attachmentsLookupTable = DatabaseLookupTable(name: DatabaseTableName.attachmentsLookup, objectIDKey: DatabaseKey.articleID, relatedObjectIDKey: DatabaseKey.attachmentID, relatedTable: attachmentsTable, relationshipName: RelationshipName.attachments)
}
// MARK: Fetching
func fetchArticles(_ feed: Feed) -> Set<Article> {
2017-08-27 00:37:15 +02:00
let feedID = feed.feedID
2017-08-27 22:03:15 +02:00
var articles = Set<Article>()
2017-08-27 00:37:15 +02:00
queue.fetchSync { (database) in
articles = self.fetchArticlesForFeedID(feedID, withLimits: true, database: database)
2017-08-27 00:37:15 +02:00
}
return articles
}
func fetchArticlesAsync(_ feed: Feed, withLimits: Bool, _ resultBlock: @escaping ArticleResultBlock) {
2017-08-27 00:37:15 +02:00
let feedID = feed.feedID
queue.fetch { (database) in
2017-08-27 00:37:15 +02:00
let articles = self.fetchArticlesForFeedID(feedID, withLimits: withLimits, database: database)
2017-08-27 00:37:15 +02:00
DispatchQueue.main.async {
resultBlock(articles)
}
}
}
2017-08-27 22:03:15 +02:00
func fetchUnreadArticles(for feeds: Set<Feed>) -> Set<Article> {
return fetchUnreadArticles(feeds.feedIDs())
}
2017-09-01 22:31:27 +02:00
// MARK: Updating
func update(_ feed: Feed, _ parsedFeed: ParsedFeed, _ completion: @escaping UpdateArticlesWithFeedCompletionBlock) {
if parsedFeed.items.isEmpty {
completion(nil, nil)
return
}
// 1. Ensure statuses for all the incoming articles.
// 2. Create incoming articles with parsedItems.
2017-09-11 15:46:32 +02:00
// 3. Ignore incoming articles that are userDeleted || (!starred and really old)
// 4. Fetch all articles for the feed.
// 5. Create array of Articles not in database and save them.
// 6. Create array of updated Articles and save whats changed.
// 7. Call back with new and updated Articles.
2017-09-05 17:53:45 +02:00
let feedID = feed.feedID
let articleIDs = Set(parsedFeed.items.map { $0.articleID })
2017-09-05 17:53:45 +02:00
self.queue.update { (database) in
2017-09-11 15:46:32 +02:00
let statusesDictionary = self.statusesTable.ensureStatusesForArticleIDs(articleIDs, database) //1
assert(statusesDictionary.count == articleIDs.count)
let allIncomingArticles = Article.articlesWithParsedItems(parsedFeed.items, self.accountID, feedID, statusesDictionary) //2
2017-09-11 15:46:32 +02:00
if allIncomingArticles.isEmpty {
self.callUpdateArticlesCompletionBlock(nil, nil, completion)
2017-09-05 17:53:45 +02:00
return
}
2017-09-11 15:46:32 +02:00
let incomingArticles = self.filterIncomingArticles(allIncomingArticles, statusesDictionary) //3
if incomingArticles.isEmpty {
self.callUpdateArticlesCompletionBlock(nil, nil, completion)
return
2017-09-05 17:53:45 +02:00
}
let fetchedArticles = self.fetchArticlesForFeedID(feedID, withLimits: false, database: database) //4
let fetchedArticlesDictionary = fetchedArticles.dictionary()
let newArticles = self.findAndSaveNewArticles(incomingArticles, fetchedArticlesDictionary, database) //5
let updatedArticles = self.findAndSaveUpdatedArticles(incomingArticles, fetchedArticlesDictionary, database) //6
self.callUpdateArticlesCompletionBlock(newArticles, updatedArticles, completion) //7
}
}
2017-09-01 22:31:27 +02:00
// MARK: Unread Counts
func fetchUnreadCounts(_ feeds: Set<Feed>, _ completion: @escaping UnreadCountCompletionBlock) {
2017-09-01 22:31:27 +02:00
let feedIDs = feeds.feedIDs()
var unreadCountDictionary = UnreadCountDictionary()
2017-09-01 22:31:27 +02:00
queue.fetch { (database) in
for feedID in feedIDs {
unreadCountDictionary[feedID] = self.fetchUnreadCount(feedID, database)
2017-09-01 22:31:27 +02:00
}
DispatchQueue.main.async() {
completion(unreadCountDictionary)
2017-09-01 22:31:27 +02:00
}
}
}
2017-09-01 22:31:27 +02:00
// MARK: Status
func mark(_ articles: Set<Article>, _ statusKey: String, _ flag: Bool) {
2017-09-16 20:04:29 +02:00
statusesTable.mark(articles.statuses(), statusKey, flag)
}
}
// MARK: - Private
2017-08-27 00:37:15 +02:00
private extension ArticlesTable {
// MARK: Fetching
func articlesWithResultSet(_ resultSet: FMResultSet, _ database: FMDatabase) -> Set<Article> {
2017-09-14 06:41:01 +02:00
// Create set of stub Articles without related objects.
// Then fetch the related objects, given the set of articleIDs.
// Then create set of Articles *with* related objects and return it.
let articleDictionaries = makeArticleDictionaries(with: resultSet)
if articleDictionaries.isEmpty {
return Set<Article>()
2017-09-14 06:41:01 +02:00
}
// Fetch related objects.
let articleIDs = articleDictionaries.map { $0[DatabaseKey.articleID] }
2017-09-14 06:41:01 +02:00
let authorsMap = authorsLookupTable.fetchRelatedObjects(for: articleIDs, in: database)
let attachmentsMap = attachmentsLookupTable.fetchRelatedObjects(for: articleIDs, in: database)
let tagsMap = tagsLookupTable.fetchRelatedObjects(for: articleIDs, in: database)
// TODO: get statusesDictionary from statusesTable
2017-09-14 06:41:01 +02:00
// Create articles with related objects.
let articles = Set(articleDictionaries.flatMap { articleWithDictionary($0, authorsMap, attachmentsMap, tagsMap) })
2017-08-27 00:37:15 +02:00
return articles
}
func articleWithDictionary(_ articleDictionary: [String: Any], _ authorsMap: RelatedObjectsMap?, _ attachmentsMap: RelatedObjectsMap?, _ tagsMap: RelatedObjectsMap?) -> Article? {
let articleID = articleDictionary[DatabaseKey.articleID]!
let status = articleDictionary[statusKey]!
let authors = authorsMap?.authors(for: articleID)
let attachments = attachmentsMap?.attachments(for: articleID)
let tags = tagsMap?.tags(for: articleID)
return Author(dictionary: articleDictionary, status: status, accountID: accountID, authors: authors, attachments: attachments, tags: tags)
}
func makeArticleDictionaries(with resultSet: FMResultSet) -> Set<[String: Any]> {
let dictionaries = resultSet.mapToSet{ (row) -> [String: Any]? in
// The resultSet is a result of a JOIN query with the statuses table,
// so we can get the statuses at the same time and avoid additional database lookups.
guard let status = statusesTable.statusWithRow(resultSet) else {
assertionFailure("Expected status.")
return nil
}
guard let let articleID = row.string(forColumn: DatabaseKey.articleID) else {
return nil
}
guard let feedID = row.string(forColumn: DatabaseKey.feedID) else {
return nil
}
guard let uniqueID = row.string(forColumn: DatabaseKey.uniqueID) else {
return nil
}
var d = [String: Any]()
d[statusKey] = status
d[DatabaseKey.articleID] = articleID
d[DatabaseKey.feedID] = feedID
d[DatabaseKey.uniqueID] = uniqueID
if let title = row.string(forColumn: DatabaseKey.title) {
d[DatabaseKey.title] = title
}
// TODO
// let contentHTML = row.string(forColumn: DatabaseKey.contentHTML)
// let contentText = row.string(forColumn: DatabaseKey.contentText)
// let url = row.string(forColumn: DatabaseKey.url)
// let externalURL = row.string(forColumn: DatabaseKey.externalURL)
// let summary = row.string(forColumn: DatabaseKey.summary)
// let imageURL = row.string(forColumn: DatabaseKey.imageURL)
// let bannerImageURL = row.string(forColumn: DatabaseKey.bannerImageURL)
// let datePublished = row.date(forColumn: DatabaseKey.datePublished)
// let dateModified = row.date(forColumn: DatabaseKey.dateModified)
// let accountInfo: AccountInfo? = nil // TODO
//
return d
}
resultSet.close()
return dictionaries
}
func makeStubArticles(with resultSet: FMResultSet) -> Set<Article> {
var stubArticles = Set<Article>()
// Note: the resultSet is a result of a JOIN query with the statuses table,
// so we can get the statuses at the same time and avoid additional database lookups.
while resultSet.next() {
guard let status = statusesTable.statusWithRow(resultSet) else {
assertionFailure("Expected status.")
continue
}
if let stubArticle = Article(row: resultSet, accountID: accountID, status: status) {
stubArticles.insert(stubArticle)
}
}
resultSet.close()
return stubArticles
}
func articleWithAttachedRelatedObjects(_ stubArticle: Article, _ authorsMap: RelatedObjectsMap?, _ attachmentsMap: RelatedObjectsMap?, _ tagsMap: RelatedObjectsMap?) -> Article {
let articleID = stubArticle.articleID
let authors = authorsMap?.authors(for: articleID)
let attachments = attachmentsMap?.attachments(for: articleID)
let tags = tagsMap?.tags(for: articleID)
let realArticle = stubArticle.articleByAttaching(authors, attachments, tags)
return realArticle
}
func fetchArticlesWithWhereClause(_ database: FMDatabase, whereClause: String, parameters: [AnyObject], withLimits: Bool) -> Set<Article> {
2017-08-27 00:37:15 +02:00
2017-08-31 22:35:48 +02:00
// 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 = 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);"
2017-08-31 22:35:48 +02:00
return articlesWithSQL(sql, parameters + [articleCutoffDate as AnyObject], database)
2017-08-27 22:03:15 +02:00
}
2017-08-31 22:35:48 +02:00
func fetchUnreadCount(_ feedID: String, _ database: FMDatabase) -> Int {
// Count only the articles that would appear in the UI.
// * Must be unread.
// * Must not be deleted.
// * Must be either 1) starred or 2) dateArrived must be newer than cutoff date.
let sql = "select count(*) from articles natural join statuses where feedID=? and read=0 and userDeleted=0 and (starred=1 or dateArrived>?);"
return numberWithSQLAndParameters(sql, [feedID, articleCutoffDate], in: database)
}
func fetchArticlesForFeedID(_ feedID: String, withLimits: Bool, database: FMDatabase) -> Set<Article> {
2017-08-27 22:03:15 +02:00
return fetchArticlesWithWhereClause(database, whereClause: "articles.feedID = ?", parameters: [feedID as AnyObject], withLimits: withLimits)
2017-08-27 22:03:15 +02:00
}
2017-08-27 00:37:15 +02:00
2017-08-27 22:03:15 +02:00
func fetchUnreadArticles(_ feedIDs: Set<String>) -> Set<Article> {
if feedIDs.isEmpty {
return Set<Article>()
2017-08-27 00:37:15 +02:00
}
2017-08-27 22:03:15 +02:00
var articles = Set<Article>()
queue.fetchSync { (database) in
// select * from articles natural join statuses where feedID in ('http://ranchero.com/xml/rss.xml') and read=0
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, withLimits: true)
2017-08-27 22:03:15 +02:00
}
return articles
2017-08-27 00:37:15 +02:00
}
2017-08-27 22:03:15 +02:00
func articlesWithSQL(_ sql: String, _ parameters: [AnyObject], _ database: FMDatabase) -> Set<Article> {
2017-08-27 00:37:15 +02:00
2017-08-27 22:03:15 +02:00
guard let resultSet = database.executeQuery(sql, withArgumentsIn: parameters) else {
return Set<Article>()
}
return articlesWithResultSet(resultSet, database)
2017-08-27 00:37:15 +02:00
}
2017-09-11 15:46:32 +02:00
// MARK: Saving Parsed Items
func callUpdateArticlesCompletionBlock(_ newArticles: Set<Article>?, _ updatedArticles: Set<Article>?, _ completion: @escaping UpdateArticlesWithFeedCompletionBlock) {
DispatchQueue.main.async {
completion(newArticles, updatedArticles)
}
}
// MARK: Save New Articles
2017-09-11 15:46:32 +02:00
func findNewArticles(_ incomingArticles: Set<Article>, _ fetchedArticlesDictionary: [String: Article]) -> Set<Article>? {
let newArticles = Set(incomingArticles.filter { fetchedArticlesDictionary[$0.articleID] == nil })
return newArticles.isEmpty ? nil : newArticles
}
func findAndSaveNewArticles(_ incomingArticles: Set<Article>, _ fetchedArticlesDictionary: [String: Article], _ database: FMDatabase) -> Set<Article>? { //5
guard let newArticles = findNewArticles(incomingArticles, fetchedArticlesDictionary) else {
return nil
}
self.saveNewArticles(newArticles, database)
return newArticles
}
func saveNewArticles(_ articles: Set<Article>, _ database: FMDatabase) {
saveRelatedObjectsForNewArticles(articles, database)
if let databaseDictionaries = articles.databaseDictionaries() {
insertRows(databaseDictionaries, insertType: .orReplace, in: database)
}
}
func saveRelatedObjectsForNewArticles(_ 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)
}
// MARK: Update Existing Articles
func articlesWithRelatedObjectChanges<T>(_ comparisonKeyPath: KeyPath<Article, Set<T>?>, _ updatedArticles: Set<Article>, _ fetchedArticles: [String: Article]) -> Set<Article> {
return updatedArticles.filter{ (updatedArticle) -> Bool in
if let fetchedArticle = fetchedArticles[updatedArticle.articleID] {
return updatedArticle[keyPath: comparisonKeyPath] != fetchedArticle[keyPath: comparisonKeyPath]
}
assertionFailure("Expected to find matching fetched article.");
return true
}
}
func updateRelatedObjects<T>(_ comparisonKeyPath: KeyPath<Article, Set<T>?>, _ updatedArticles: Set<Article>, _ fetchedArticles: [String: Article], _ lookupTable: DatabaseLookupTable, _ database: FMDatabase) {
let articlesWithChanges = articlesWithRelatedObjectChanges(comparisonKeyPath, updatedArticles, fetchedArticles)
if !articlesWithChanges.isEmpty {
lookupTable.saveRelatedObjects(for: articlesWithChanges.databaseObjects(), in: database)
}
}
func saveUpdatedRelatedObjects(_ updatedArticles: Set<Article>, _ fetchedArticles: [String: Article], _ database: FMDatabase) {
updateRelatedObjects(\Article.tags, updatedArticles, fetchedArticles, tagsLookupTable, database)
updateRelatedObjects(\Article.authors, updatedArticles, fetchedArticles, authorsLookupTable, database)
updateRelatedObjects(\Article.attachments, updatedArticles, fetchedArticles, attachmentsLookupTable, database)
}
2017-09-11 15:46:32 +02:00
func findUpdatedArticles(_ incomingArticles: Set<Article>, _ fetchedArticlesDictionary: [String: Article]) -> Set<Article>? {
let updatedArticles = incomingArticles.filter{ (incomingArticle) -> Bool in //6
if let existingArticle = fetchedArticlesDictionary[incomingArticle.articleID] {
if existingArticle != incomingArticle {
return true
}
}
return false
}
return updatedArticles.isEmpty ? nil : updatedArticles
}
func findAndSaveUpdatedArticles(_ incomingArticles: Set<Article>, _ fetchedArticlesDictionary: [String: Article], _ database: FMDatabase) -> Set<Article>? { //6
guard let updatedArticles = findUpdatedArticles(incomingArticles, fetchedArticlesDictionary) else {
return nil
}
saveUpdatedArticles(Set(updatedArticles), fetchedArticlesDictionary, database)
return updatedArticles
}
func saveUpdatedArticles(_ updatedArticles: Set<Article>, _ fetchedArticles: [String: Article], _ database: FMDatabase) {
saveUpdatedRelatedObjects(updatedArticles, fetchedArticles, database)
for updatedArticle in updatedArticles {
2017-09-09 21:24:30 +02:00
saveUpdatedArticle(updatedArticle, fetchedArticles, database)
}
}
func saveUpdatedArticle(_ updatedArticle: Article, _ fetchedArticles: [String: Article], _ database: FMDatabase) {
// Only update exactly what has changed in the Article (if anything).
// Untested theory: this gets us better performance and less database fragmentation.
2017-09-14 06:41:01 +02:00
guard let fetchedArticle = fetchedArticles[updatedArticle.articleID] else {
assertionFailure("Expected to find matching fetched article.");
saveNewArticles(Set([updatedArticle]), database)
return
}
2017-09-14 06:41:01 +02:00
guard let changesDictionary = updatedArticle.changesFrom(fetchedArticle), changesDictionary.count > 0 else {
// Not unexpected. There may be no changes.
return
}
updateRowsWithDictionary(changesDictionary, whereKey: DatabaseKey.articleID, matches: updatedArticle.articleID, database: 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
}
2017-09-11 15:46:32 +02:00
func filterIncomingArticles(_ articles: Set<Article>, _ statuses: [String: ArticleStatus]) -> Set<Article> {
// Drop Articles that we can ignore.
return Set(articles.filter{ (article) -> Bool in
let articleID = article.articleID
if let status = statuses[articleID] {
return !statusIndicatesArticleIsIgnorable(status)
}
2017-09-11 15:46:32 +02:00
assertionFailure("Expected a status for each Article.")
return true
})
}
2017-08-27 00:37:15 +02:00
}