NetNewsWire/Frameworks/Database/ArticlesTable.swift

279 lines
8.4 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// 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
private weak var account: Account?
private let queue: RSDatabaseQueue
private let statusesTable: StatusesTable
private let authorsLookupTable: DatabaseLookupTable
private let attachmentsLookupTable: DatabaseLookupTable
private let tagsLookupTable: DatabaseLookupTable
private let articleCache = ArticleCache()
// TODO: update articleCutoffDate as time passes and based on user preferences.
private var articleCutoffDate = NSDate.rs_dateWithNumberOfDays(inThePast: 3 * 31)!
init(name: String, account: Account, queue: RSDatabaseQueue) {
self.name = name
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)
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> {
let feedID = feed.feedID
var articles = Set<Article>()
queue.fetchSync { (database: FMDatabase!) -> Void in
articles = self.fetchArticlesForFeedID(feedID, database: database)
}
return articleCache.uniquedArticles(articles)
}
func fetchArticlesAsync(_ feed: Feed, _ resultBlock: @escaping ArticleResultBlock) {
let feedID = feed.feedID
queue.fetch { (database: FMDatabase!) -> Void in
let fetchedArticles = self.fetchArticlesForFeedID(feedID, database: database)
DispatchQueue.main.async {
let articles = self.articleCache.uniquedArticles(fetchedArticles)
resultBlock(articles)
}
}
}
func fetchUnreadArticles(for feeds: Set<Feed>) -> Set<Article> {
return fetchUnreadArticles(feeds.feedIDs())
}
// MARK: Updating
func update(_ feed: Feed, _ parsedFeed: ParsedFeed, _ completion: @escaping RSVoidCompletionBlock) {
// TODO
}
// MARK: Unread Counts
func fetchUnreadCounts(_ feeds: Set<Feed>, _ completion: @escaping UnreadCountCompletionBlock) {
let feedIDs = feeds.feedIDs()
var unreadCountTable = UnreadCountTable()
queue.fetch { (database) in
for feedID in feedIDs {
unreadCountTable[feedID] = self.fetchUnreadCount(feedID, database)
}
DispatchQueue.main.async() {
completion(unreadCountTable)
}
}
}
// MARK: Status
func mark(_ articles: Set<Article>, _ statusKey: String, _ flag: Bool) {
// Sets flag in both memory and in database.
let articleIDs = articles.flatMap { (article) -> String? in
guard let status = article.status else {
assertionFailure("Each article must have a status.")
return nil
}
if status.boolStatus(forKey: statusKey) == flag {
return nil
}
status.setBoolStatus(flag, forKey: statusKey)
return article.articleID
}
if articleIDs.isEmpty {
return
}
queue.update { (database) in
self.statusesTable.markArticleIDs(Set(articleIDs), statusKey, flag, database)
}
}
}
// MARK: -
private extension ArticlesTable {
// MARK: Fetching
func attachRelatedObjects(_ articles: Set<Article>, _ database: FMDatabase) {
let articleArray = articles.map { $0 as DatabaseObject }
authorsLookupTable.attachRelatedObjects(to: articleArray, in: database)
attachmentsLookupTable.attachRelatedObjects(to: articleArray, in: database)
tagsLookupTable.attachRelatedObjects(to: articleArray, in: database)
// In theory, its impossible to have a fetched article without a status.
// Lets handle that impossibility anyway.
// Remember that, if nothing else, the user can edit the SQLite database,
// and thus could delete all their statuses.
statusesTable.ensureStatusesForArticles(articles, database)
}
func articleWithRow(_ row: FMResultSet) -> Article? {
guard let account = account else {
return nil
}
guard let article = Article(row: row, account: account) else {
return nil
}
// Note: the row is a result of a JOIN query with the statuses table,
// so we can get the status at the same time and avoid additional database lookups.
article.status = statusesTable.statusWithRow(row)
return article
}
func articlesWithResultSet(_ resultSet: FMResultSet, _ database: FMDatabase) -> Set<Article> {
let articles = resultSet.mapToSet(articleWithRow)
attachRelatedObjects(articles, database)
return articles
}
func fetchArticlesWithWhereClause(_ database: FMDatabase, whereClause: String, parameters: [AnyObject]) -> 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>?);"
return articlesWithSQL(sql, parameters + [articleCutoffDate as AnyObject], database)
}
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, database: FMDatabase) -> Set<Article> {
return fetchArticlesWithWhereClause(database, whereClause: "articles.feedID = ?", parameters: [feedID as AnyObject])
}
func fetchUnreadArticles(_ feedIDs: Set<String>) -> Set<Article> {
if feedIDs.isEmpty {
return Set<Article>()
}
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)
}
return articleCache.uniquedArticles(articles)
}
func articlesWithSQL(_ sql: String, _ parameters: [AnyObject], _ database: FMDatabase) -> Set<Article> {
guard let resultSet = database.executeQuery(sql, withArgumentsIn: parameters) else {
return Set<Article>()
}
return articlesWithResultSet(resultSet, database)
}
}
// MARK: -
private struct ArticleCache {
// Main thread only unlike the other object caches.
// The cache contains a given article only until all outside references are gone.
// Cache key is articleID.
private let articlesMapTable: NSMapTable<NSString, Article> = NSMapTable.weakToWeakObjects()
func uniquedArticles(_ articles: Set<Article>) -> Set<Article> {
var articlesToReturn = Set<Article>()
for article in articles {
let articleID = article.articleID
if let cachedArticle = cachedArticle(for: articleID) {
articlesToReturn.insert(cachedArticle)
}
else {
articlesToReturn.insert(article)
addToCache(article)
}
}
// At this point, every Article must have an attached Status.
assert(articlesToReturn.eachHasAStatus())
return articlesToReturn
}
private func cachedArticle(for articleID: String) -> Article? {
return articlesMapTable.object(forKey: articleID as NSString)
}
private func addToCache(_ article: Article) {
articlesMapTable.setObject(article, forKey: article.articleID as NSString)
}
}