mirror of
https://github.com/Ranchero-Software/NetNewsWire
synced 2025-08-12 06:26:36 +00:00
Move local modules into a folder named Modules.
This commit is contained in:
@@ -0,0 +1,363 @@
|
||||
//
|
||||
// ArticlesDatabase.swift
|
||||
// NetNewsWire
|
||||
//
|
||||
// Created by Brent Simmons on 7/20/15.
|
||||
// Copyright © 2015 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Database
|
||||
import FMDB
|
||||
import Articles
|
||||
import Parser
|
||||
|
||||
public typealias UnreadCountDictionary = [String: Int] // feedID: unreadCount
|
||||
|
||||
public struct ArticleChanges: Sendable {
|
||||
public let newArticles: Set<Article>?
|
||||
public let updatedArticles: Set<Article>?
|
||||
public let deletedArticles: Set<Article>?
|
||||
|
||||
public init() {
|
||||
self.newArticles = Set<Article>()
|
||||
self.updatedArticles = Set<Article>()
|
||||
self.deletedArticles = Set<Article>()
|
||||
}
|
||||
|
||||
public init(newArticles: Set<Article>?, updatedArticles: Set<Article>?, deletedArticles: Set<Article>?) {
|
||||
self.newArticles = newArticles
|
||||
self.updatedArticles = updatedArticles
|
||||
self.deletedArticles = deletedArticles
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch articles and unread counts. Save articles. Mark as read/unread and starred/unstarred.
|
||||
public actor ArticlesDatabase {
|
||||
|
||||
public enum RetentionStyle: Sendable {
|
||||
/// Local and iCloud: article retention is defined by contents of feed
|
||||
case feedBased
|
||||
/// Feedbin, Feedly, etc.: article retention is defined by external system
|
||||
case syncSystem
|
||||
}
|
||||
|
||||
private var database: FMDatabase?
|
||||
private var databasePath: String
|
||||
private let retentionStyle: RetentionStyle
|
||||
private let articlesTable: ArticlesTable
|
||||
|
||||
public init(databasePath: String, accountID: String, retentionStyle: RetentionStyle) {
|
||||
|
||||
let database = FMDatabase.openAndSetUpDatabase(path: databasePath)
|
||||
database.runCreateStatements(ArticlesDatabase.creationStatements)
|
||||
|
||||
self.database = database
|
||||
self.databasePath = databasePath
|
||||
self.retentionStyle = retentionStyle
|
||||
self.articlesTable = ArticlesTable(accountID: accountID, retentionStyle: retentionStyle)
|
||||
|
||||
// Migrate from older schemas
|
||||
database.beginTransaction()
|
||||
if !database.columnExists("searchRowID", inTableWithName: DatabaseTableName.articles) {
|
||||
database.executeStatements("ALTER TABLE articles add column searchRowID INTEGER;")
|
||||
}
|
||||
database.executeStatements("CREATE INDEX if not EXISTS articles_searchRowID on articles(searchRowID);")
|
||||
database.executeStatements("DROP TABLE if EXISTS tags;DROP INDEX if EXISTS tags_tagName_index;DROP INDEX if EXISTS articles_feedID_index;DROP INDEX if EXISTS statuses_read_index;DROP TABLE if EXISTS attachments;DROP TABLE if EXISTS attachmentsLookup;")
|
||||
database.commit()
|
||||
|
||||
Task {
|
||||
await self.indexUnindexedArticles()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Articles
|
||||
|
||||
public func articles(feedID: String) throws -> Set<Article> {
|
||||
|
||||
guard let database else {
|
||||
throw DatabaseError.suspended
|
||||
}
|
||||
return articlesTable.articles(feedID: feedID, database: database)
|
||||
}
|
||||
|
||||
public func articles(feedIDs: Set<String>) throws -> Set<Article> {
|
||||
|
||||
guard let database else {
|
||||
throw DatabaseError.suspended
|
||||
}
|
||||
return articlesTable.articles(feedIDs: feedIDs, database: database)
|
||||
}
|
||||
|
||||
public func articles(articleIDs: Set<String>) throws -> Set<Article> {
|
||||
|
||||
guard let database else {
|
||||
throw DatabaseError.suspended
|
||||
}
|
||||
return articlesTable.articles(articleIDs: articleIDs, database: database)
|
||||
}
|
||||
|
||||
public func unreadArticles(feedIDs: Set<String>, limit: Int? = nil) throws -> Set<Article> {
|
||||
|
||||
guard let database else {
|
||||
throw DatabaseError.suspended
|
||||
}
|
||||
return articlesTable.unreadArticles(feedIDs: feedIDs, limit: limit, database: database)
|
||||
}
|
||||
|
||||
public func todayArticles(feedIDs: Set<String>, limit: Int? = nil) throws -> Set<Article> {
|
||||
|
||||
guard let database else {
|
||||
throw DatabaseError.suspended
|
||||
}
|
||||
return articlesTable.todayArticles(feedIDs: feedIDs, cutoffDate: todayCutoffDate(), limit: limit, database: database)
|
||||
}
|
||||
|
||||
public func starredArticles(feedIDs: Set<String>, limit: Int? = nil) throws -> Set<Article> {
|
||||
|
||||
guard let database else {
|
||||
throw DatabaseError.suspended
|
||||
}
|
||||
return articlesTable.starredArticles(feedIDs: feedIDs, limit: limit, database: database)
|
||||
}
|
||||
|
||||
public func articlesMatching(searchString: String, feedIDs: Set<String>) throws -> Set<Article> {
|
||||
|
||||
guard let database else {
|
||||
throw DatabaseError.suspended
|
||||
}
|
||||
return articlesTable.articlesMatching(searchString: searchString, feedIDs: feedIDs, database: database)
|
||||
}
|
||||
|
||||
public func articlesMatching(searchString: String, articleIDs: Set<String>) throws -> Set<Article> {
|
||||
|
||||
guard let database else {
|
||||
throw DatabaseError.suspended
|
||||
}
|
||||
return articlesTable.articlesMatching(searchString: searchString, articleIDs: articleIDs, database: database)
|
||||
}
|
||||
|
||||
// MARK: - Unread Counts
|
||||
|
||||
/// Fetch all non-zero unread counts.
|
||||
public func allUnreadCounts() throws -> UnreadCountDictionary {
|
||||
|
||||
guard let database else {
|
||||
throw DatabaseError.suspended
|
||||
}
|
||||
return articlesTable.allUnreadCounts(database: database)
|
||||
}
|
||||
|
||||
/// Fetch unread count for a single feed.
|
||||
public func unreadCount(feedID: String) throws -> Int? {
|
||||
|
||||
guard let database else {
|
||||
throw DatabaseError.suspended
|
||||
}
|
||||
return articlesTable.unreadCount(feedID: feedID, database: database)
|
||||
}
|
||||
|
||||
/// Fetch non-zero unread counts for given feedIDs.
|
||||
public func unreadCounts(feedIDs: Set<String>) throws -> UnreadCountDictionary {
|
||||
|
||||
guard let database else {
|
||||
throw DatabaseError.suspended
|
||||
}
|
||||
return articlesTable.unreadCounts(feedIDs: feedIDs, database: database)
|
||||
}
|
||||
|
||||
public func unreadCountForToday(feedIDs: Set<String>) throws -> Int? {
|
||||
|
||||
try unreadCount(feedIDs: feedIDs, since: todayCutoffDate())
|
||||
}
|
||||
|
||||
public func unreadCount(feedIDs: Set<String>, since: Date) throws -> Int? {
|
||||
|
||||
guard let database else {
|
||||
throw DatabaseError.suspended
|
||||
}
|
||||
return articlesTable.unreadCount(feedIDs: feedIDs, since: since, database: database)
|
||||
}
|
||||
|
||||
public func starredAndUnreadCount(feedIDs: Set<String>) throws -> Int? {
|
||||
|
||||
guard let database else {
|
||||
throw DatabaseError.suspended
|
||||
}
|
||||
return articlesTable.starredAndUnreadCount(feedIDs: feedIDs, database: database)
|
||||
}
|
||||
|
||||
// MARK: - Saving, Updating, and Deleting Articles
|
||||
|
||||
/// Update articles and save new ones — for feed-based systems (local and iCloud).
|
||||
public func update(parsedItems: Set<ParsedItem>, feedID: String, deleteOlder: Bool) throws -> ArticleChanges {
|
||||
|
||||
precondition(retentionStyle == .feedBased)
|
||||
guard let database else {
|
||||
throw DatabaseError.suspended
|
||||
}
|
||||
return articlesTable.update(parsedItems: parsedItems, feedID: feedID, deleteOlder: deleteOlder, database: database)
|
||||
}
|
||||
|
||||
/// Update articles and save new ones — for sync systems (Feedbin, Feedly, etc.).
|
||||
public func update(feedIDsAndItems: [String: Set<ParsedItem>], defaultRead: Bool) throws -> ArticleChanges {
|
||||
|
||||
precondition(retentionStyle == .syncSystem)
|
||||
guard let database else {
|
||||
throw DatabaseError.suspended
|
||||
}
|
||||
return articlesTable.update(feedIDsAndItems: feedIDsAndItems, read: defaultRead, database: database)
|
||||
}
|
||||
|
||||
/// Delete articles
|
||||
public func delete(articleIDs: Set<String>) throws {
|
||||
|
||||
guard let database else {
|
||||
throw DatabaseError.suspended
|
||||
}
|
||||
return articlesTable.delete(articleIDs: articleIDs, database: database)
|
||||
}
|
||||
|
||||
// MARK: - Status
|
||||
|
||||
/// Fetch the articleIDs of unread articles.
|
||||
public func unreadArticleIDs() throws -> Set<String>? {
|
||||
|
||||
guard let database else {
|
||||
throw DatabaseError.suspended
|
||||
}
|
||||
return articlesTable.unreadArticleIDs(database: database)
|
||||
}
|
||||
|
||||
/// Fetch the articleIDs of starred articles.
|
||||
public func starredArticleIDs() throws -> Set<String>? {
|
||||
|
||||
guard let database else {
|
||||
throw DatabaseError.suspended
|
||||
}
|
||||
return articlesTable.starredArticleIDs(database: database)
|
||||
}
|
||||
|
||||
/// Fetch articleIDs for articles that we should have, but don’t. These articles are either (starred) or (newer than the article cutoff date).
|
||||
public func articleIDsForStatusesWithoutArticlesNewerThanCutoffDate() throws -> Set<String>? {
|
||||
|
||||
guard let database else {
|
||||
throw DatabaseError.suspended
|
||||
}
|
||||
return articlesTable.articleIDsForStatusesWithoutArticlesNewerThanCutoffDate(database: database)
|
||||
}
|
||||
|
||||
public func mark(articles: Set<Article>, statusKey: ArticleStatus.Key, flag: Bool) throws -> Set<ArticleStatus>? {
|
||||
|
||||
guard let database else {
|
||||
throw DatabaseError.suspended
|
||||
}
|
||||
return articlesTable.mark(articles: articles, statusKey: statusKey, flag: flag, database: database)
|
||||
}
|
||||
|
||||
public func mark(articleIDs: Set<String>, statusKey: ArticleStatus.Key, flag: Bool) throws {
|
||||
|
||||
guard let database else {
|
||||
throw DatabaseError.suspended
|
||||
}
|
||||
articlesTable.mark(articleIDs: articleIDs, statusKey: statusKey, flag: flag, database: database)
|
||||
}
|
||||
|
||||
/// Create statuses for specified articleIDs. For existing statuses, don’t do anything.
|
||||
/// For newly-created statuses, mark them as read and not-starred.
|
||||
public func createStatusesIfNeeded(articleIDs: Set<String>) throws {
|
||||
|
||||
guard let database else {
|
||||
throw DatabaseError.suspended
|
||||
}
|
||||
return articlesTable.createStatusesIfNeeded(articleIDs: articleIDs, database: database)
|
||||
}
|
||||
|
||||
// MARK: - Suspend and Resume (for iOS)
|
||||
|
||||
public func suspend() {
|
||||
#if os(iOS)
|
||||
database?.close()
|
||||
database = nil
|
||||
#endif
|
||||
}
|
||||
|
||||
public func resume() {
|
||||
#if os(iOS)
|
||||
if database == nil {
|
||||
self.database = FMDatabase.openAndSetUpDatabase(path: databasePath)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
// MARK: - Caches
|
||||
|
||||
/// Call to free up some memory. Should be done when the app is backgrounded, for instance.
|
||||
/// This does not empty *all* caches — just the ones that are empty-able.
|
||||
public func emptyCaches() {
|
||||
|
||||
articlesTable.emptyCaches()
|
||||
}
|
||||
|
||||
// MARK: - Cleanup
|
||||
|
||||
/// Calls the various clean-up functions. To be used only at startup.
|
||||
///
|
||||
/// This prevents the database from growing forever. If we didn’t do this:
|
||||
/// 1) The database would grow to an inordinate size, and
|
||||
/// 2) the app would become very slow.
|
||||
public func cleanupDatabaseAtStartup(subscribedToFeedIDs: Set<String>) throws {
|
||||
|
||||
guard let database else {
|
||||
throw DatabaseError.suspended
|
||||
}
|
||||
|
||||
if retentionStyle == .syncSystem {
|
||||
articlesTable.deleteOldArticles(database: database)
|
||||
}
|
||||
articlesTable.deleteArticlesNotInSubscribedToFeedIDs(subscribedToFeedIDs, database: database)
|
||||
articlesTable.deleteOldStatuses(database: database)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private extension ArticlesDatabase {
|
||||
|
||||
static let creationStatements = """
|
||||
CREATE TABLE if not EXISTS articles (articleID TEXT NOT NULL PRIMARY KEY, feedID TEXT NOT NULL, uniqueID TEXT NOT NULL, title TEXT, contentHTML TEXT, contentText TEXT, url TEXT, externalURL TEXT, summary TEXT, imageURL TEXT, bannerImageURL TEXT, datePublished DATE, dateModified DATE, searchRowID INTEGER);
|
||||
|
||||
CREATE TABLE if not EXISTS statuses (articleID TEXT NOT NULL PRIMARY KEY, read BOOL NOT NULL DEFAULT 0, starred BOOL NOT NULL DEFAULT 0, dateArrived DATE NOT NULL DEFAULT 0);
|
||||
|
||||
CREATE TABLE if not EXISTS authors (authorID TEXT NOT NULL PRIMARY KEY, name TEXT, url TEXT, avatarURL TEXT, emailAddress TEXT);
|
||||
CREATE TABLE if not EXISTS authorsLookup (authorID TEXT NOT NULL, articleID TEXT NOT NULL, PRIMARY KEY(authorID, articleID));
|
||||
|
||||
CREATE INDEX if not EXISTS articles_feedID_datePublished_articleID on articles (feedID, datePublished, articleID);
|
||||
|
||||
CREATE INDEX if not EXISTS statuses_starred_index on statuses (starred);
|
||||
|
||||
CREATE VIRTUAL TABLE if not EXISTS search using fts4(title, body);
|
||||
|
||||
CREATE TRIGGER if not EXISTS articles_after_delete_trigger_delete_search_text after delete on articles begin delete from search where rowid = OLD.searchRowID; end;
|
||||
"""
|
||||
|
||||
func todayCutoffDate() -> Date {
|
||||
// 24 hours previous. This is used by the Today smart feed, which should not actually empty out at midnight.
|
||||
return Date(timeIntervalSinceNow: -(60 * 60 * 24)) // This does not need to be more precise.
|
||||
}
|
||||
|
||||
func indexUnindexedArticles() {
|
||||
|
||||
guard let database else {
|
||||
return // not an error in this case
|
||||
}
|
||||
|
||||
let didIndexArticles = articlesTable.indexUnindexedArticles(database: database)
|
||||
if didIndexArticles {
|
||||
// Indexing happens in bunches. Continue until there are no more articles to index.
|
||||
Task {
|
||||
self.indexUnindexedArticles()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,775 @@
|
||||
//
|
||||
// File.swift
|
||||
//
|
||||
//
|
||||
// Created by Brent Simmons on 3/10/24.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import FMDB
|
||||
import Database
|
||||
import Articles
|
||||
import Parser
|
||||
|
||||
final class ArticlesTable {
|
||||
|
||||
let name = DatabaseTableName.articles
|
||||
|
||||
private let accountID: String
|
||||
private let retentionStyle: ArticlesDatabase.RetentionStyle
|
||||
private var articlesCache = [String: Article]()
|
||||
private let statusesTable = StatusesTable()
|
||||
private let authorsTable = AuthorsTable()
|
||||
private let searchTable = SearchTable()
|
||||
|
||||
private lazy var authorsLookupTable: DatabaseLookupTable = {
|
||||
DatabaseLookupTable(name: DatabaseTableName.authorsLookup, objectIDKey: DatabaseKey.articleID, relatedObjectIDKey: DatabaseKey.authorID, relatedTable: authorsTable, relationshipName: RelationshipName.authors)
|
||||
}()
|
||||
|
||||
// TODO: update articleCutoffDate as time passes and based on user preferences.
|
||||
private let articleCutoffDate = Date().bySubtracting(days: 90)
|
||||
|
||||
private typealias ArticlesFetchMethod = (FMDatabase) -> Set<Article>
|
||||
|
||||
init(accountID: String, retentionStyle: ArticlesDatabase.RetentionStyle) {
|
||||
|
||||
self.accountID = accountID
|
||||
self.retentionStyle = retentionStyle
|
||||
}
|
||||
|
||||
// MARK: - Fetching Articles
|
||||
|
||||
func articles(feedID: String, database: FMDatabase) -> Set<Article> {
|
||||
|
||||
fetchArticlesWithWhereClause(database, whereClause: "articles.feedID = ?", parameters: [feedID as AnyObject])
|
||||
}
|
||||
|
||||
func articles(feedIDs: Set<String>, database: FMDatabase) -> Set<Article> {
|
||||
|
||||
// select * from articles natural join statuses where feedID in ('http://ranchero.com/xml/rss.xml') and read=0
|
||||
|
||||
if feedIDs.isEmpty {
|
||||
return Set<Article>()
|
||||
}
|
||||
|
||||
let parameters = feedIDs.map { $0 as AnyObject }
|
||||
let placeholders = NSString.rs_SQLValueList(withPlaceholders: UInt(feedIDs.count))!
|
||||
let whereClause = "feedID in \(placeholders)"
|
||||
|
||||
return fetchArticlesWithWhereClause(database, whereClause: whereClause, parameters: parameters)
|
||||
}
|
||||
|
||||
func articles(articleIDs: Set<String>, database: FMDatabase) -> Set<Article> {
|
||||
|
||||
if articleIDs.isEmpty {
|
||||
return Set<Article>()
|
||||
}
|
||||
|
||||
let parameters = articleIDs.map { $0 as AnyObject }
|
||||
let placeholders = NSString.rs_SQLValueList(withPlaceholders: UInt(articleIDs.count))!
|
||||
let whereClause = "articleID in \(placeholders)"
|
||||
|
||||
return fetchArticlesWithWhereClause(database, whereClause: whereClause, parameters: parameters)
|
||||
}
|
||||
|
||||
func unreadArticles(feedIDs: Set<String>, limit: Int?, database: FMDatabase) -> Set<Article> {
|
||||
|
||||
// select * from articles natural join statuses where feedID in ('http://ranchero.com/xml/rss.xml') and read=0
|
||||
|
||||
if feedIDs.isEmpty {
|
||||
return Set<Article>()
|
||||
}
|
||||
let parameters = feedIDs.map { $0 as AnyObject }
|
||||
let placeholders = NSString.rs_SQLValueList(withPlaceholders: UInt(feedIDs.count))!
|
||||
var whereClause = "feedID in \(placeholders) and read=0"
|
||||
if let limit = limit {
|
||||
whereClause.append(" order by coalesce(datePublished, dateModified, dateArrived) desc limit \(limit)")
|
||||
}
|
||||
|
||||
return fetchArticlesWithWhereClause(database, whereClause: whereClause, parameters: parameters)
|
||||
}
|
||||
|
||||
func todayArticles(feedIDs: Set<String>, cutoffDate: Date, limit: Int?, database: FMDatabase) -> Set<Article> {
|
||||
|
||||
fetchArticlesSince(feedIDs: feedIDs, cutoffDate: cutoffDate, limit: limit, database: database)
|
||||
}
|
||||
|
||||
func starredArticles(feedIDs: Set<String>, limit: Int?, database: FMDatabase) -> Set<Article> {
|
||||
|
||||
// select * from articles natural join statuses where feedID in ('http://ranchero.com/xml/rss.xml') and starred=1;
|
||||
|
||||
if feedIDs.isEmpty {
|
||||
return Set<Article>()
|
||||
}
|
||||
|
||||
let parameters = feedIDs.map { $0 as AnyObject }
|
||||
let placeholders = NSString.rs_SQLValueList(withPlaceholders: UInt(feedIDs.count))!
|
||||
var whereClause = "feedID in \(placeholders) and starred=1"
|
||||
if let limit = limit {
|
||||
whereClause.append(" order by coalesce(datePublished, dateModified, dateArrived) desc limit \(limit)")
|
||||
}
|
||||
|
||||
return fetchArticlesWithWhereClause(database, whereClause: whereClause, parameters: parameters)
|
||||
}
|
||||
|
||||
func articlesMatching(searchString: String, feedIDs: Set<String>, database: FMDatabase) -> Set<Article> {
|
||||
|
||||
let articles = fetchArticlesMatching(searchString, database)
|
||||
|
||||
// TODO: include the feedIDs in the SQL rather than filtering here.
|
||||
return articles.filter{ feedIDs.contains($0.feedID) }
|
||||
}
|
||||
|
||||
func articlesMatching(searchString: String, articleIDs: Set<String>, database: FMDatabase) -> Set<Article> {
|
||||
|
||||
let articles = fetchArticlesMatching(searchString, database)
|
||||
|
||||
// TODO: include the articleIDs in the SQL rather than filtering here.
|
||||
return articles.filter{ articleIDs.contains($0.articleID) }
|
||||
}
|
||||
|
||||
// MARK: - Unread Counts
|
||||
|
||||
func allUnreadCounts(database: FMDatabase) -> UnreadCountDictionary {
|
||||
|
||||
var unreadCountDictionary = UnreadCountDictionary()
|
||||
|
||||
let sql = "select distinct feedID, count(*) from articles natural join statuses where read=0 group by feedID;"
|
||||
guard let resultSet = database.executeQuery(sql, withArgumentsIn: nil) else {
|
||||
return unreadCountDictionary
|
||||
}
|
||||
|
||||
while resultSet.next() {
|
||||
let unreadCount = resultSet.long(forColumnIndex: 1)
|
||||
if let feedID = resultSet.string(forColumnIndex: 0) {
|
||||
unreadCountDictionary[feedID] = unreadCount
|
||||
}
|
||||
}
|
||||
resultSet.close()
|
||||
|
||||
return unreadCountDictionary
|
||||
}
|
||||
|
||||
func unreadCount(feedID: String, database: FMDatabase) -> Int? {
|
||||
|
||||
let sql = "select count(*) from articles natural join statuses where feedID=? and read=0;"
|
||||
let unreadCount = database.count(sql: sql, parameters: [feedID], tableName: name)
|
||||
return unreadCount
|
||||
}
|
||||
|
||||
// Unread count for starred articles in feedIDs.
|
||||
func starredAndUnreadCount(feedIDs: Set<String>, database: FMDatabase) -> Int? {
|
||||
|
||||
if feedIDs.isEmpty {
|
||||
return 0
|
||||
}
|
||||
|
||||
let placeholders = NSString.rs_SQLValueList(withPlaceholders: UInt(feedIDs.count))!
|
||||
let sql = "select count(*) from articles natural join statuses where feedID in \(placeholders) and read=0 and starred=1;"
|
||||
let parameters = Array(feedIDs) as [Any]
|
||||
|
||||
let unreadCount = database.count(sql: sql, parameters: parameters, tableName: name)
|
||||
return unreadCount
|
||||
}
|
||||
|
||||
func unreadCounts(feedIDs: Set<String>, database: FMDatabase) -> UnreadCountDictionary {
|
||||
|
||||
var unreadCountDictionary = UnreadCountDictionary()
|
||||
|
||||
let placeholders = NSString.rs_SQLValueList(withPlaceholders: UInt(feedIDs.count))!
|
||||
let sql = "select distinct feedID, count(*) from articles natural join statuses where feedID in \(placeholders) and read=0 group by feedID;"
|
||||
|
||||
let parameters = Array(feedIDs) as [Any]
|
||||
|
||||
guard let resultSet = database.executeQuery(sql, withArgumentsIn: parameters) else {
|
||||
return unreadCountDictionary
|
||||
}
|
||||
|
||||
while resultSet.next() {
|
||||
let unreadCount = resultSet.long(forColumnIndex: 1)
|
||||
if let feedID = resultSet.string(forColumnIndex: 0) {
|
||||
unreadCountDictionary[feedID] = unreadCount
|
||||
}
|
||||
}
|
||||
resultSet.close()
|
||||
|
||||
return unreadCountDictionary
|
||||
}
|
||||
|
||||
func unreadCount(feedIDs: Set<String>, since: Date, database: FMDatabase) -> Int? {
|
||||
|
||||
if feedIDs.isEmpty {
|
||||
return 0
|
||||
}
|
||||
|
||||
let placeholders = NSString.rs_SQLValueList(withPlaceholders: UInt(feedIDs.count))!
|
||||
let sql = "select count(*) from articles natural join statuses where feedID in \(placeholders) and (datePublished > ? or (datePublished is null and dateArrived > ?)) and read=0;"
|
||||
|
||||
var parameters = [Any]()
|
||||
parameters += Array(feedIDs) as [Any]
|
||||
parameters += [since] as [Any]
|
||||
parameters += [since] as [Any]
|
||||
|
||||
let unreadCount = database.count(sql: sql, parameters: parameters, tableName: name)
|
||||
return unreadCount
|
||||
}
|
||||
|
||||
// MARK: - Saving, Updating, and Deleting Articles
|
||||
|
||||
/// Update articles and save new ones — for feed-based systems (local and iCloud).
|
||||
func update(parsedItems: Set<ParsedItem>, feedID: String, deleteOlder: Bool, database: FMDatabase) -> ArticleChanges {
|
||||
|
||||
precondition(retentionStyle == .feedBased)
|
||||
|
||||
if parsedItems.isEmpty {
|
||||
return ArticleChanges()
|
||||
}
|
||||
|
||||
// 1. Ensure statuses for all the incoming articles.
|
||||
// 2. Create incoming articles with parsedItems.
|
||||
// 3. [Deleted - this step is no longer needed]
|
||||
// 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 what’s changed.
|
||||
// 7. Call back with new and updated Articles.
|
||||
// 8. Delete Articles in database no longer present in the feed.
|
||||
// 9. Update search index.
|
||||
|
||||
let articleIDs = parsedItems.articleIDs()
|
||||
|
||||
let (statusesDictionary, _) = statusesTable.ensureStatusesForArticleIDs(articleIDs, false, database) //1
|
||||
assert(statusesDictionary.count == articleIDs.count)
|
||||
|
||||
let incomingArticles = Article.articlesWithParsedItems(parsedItems, feedID, accountID, statusesDictionary) //2
|
||||
if incomingArticles.isEmpty {
|
||||
return ArticleChanges()
|
||||
}
|
||||
|
||||
let fetchedArticles = articles(feedID: feedID, database: database) //4
|
||||
let fetchedArticlesDictionary = fetchedArticles.dictionary()
|
||||
|
||||
let newArticles = findAndSaveNewArticles(incomingArticles, fetchedArticlesDictionary, database) //5
|
||||
let updatedArticles = findAndSaveUpdatedArticles(incomingArticles, fetchedArticlesDictionary, database) //6
|
||||
|
||||
// Articles to delete are 1) not starred and 2) older than 30 days and 3) no longer in feed.
|
||||
let articlesToDelete: Set<Article>
|
||||
if deleteOlder {
|
||||
let cutoffDate = Date().bySubtracting(days: 30)
|
||||
articlesToDelete = fetchedArticles.filter { (article) -> Bool in
|
||||
return !article.status.starred && article.status.dateArrived < cutoffDate && !articleIDs.contains(article.articleID)
|
||||
}
|
||||
} else {
|
||||
articlesToDelete = Set<Article>()
|
||||
}
|
||||
|
||||
addArticlesToCache(newArticles)
|
||||
addArticlesToCache(updatedArticles)
|
||||
|
||||
// 8. Delete articles no longer in feed.
|
||||
let articleIDsToDelete = articlesToDelete.articleIDs()
|
||||
if !articleIDsToDelete.isEmpty {
|
||||
removeArticles(articleIDsToDelete, database)
|
||||
removeArticleIDsFromCache(articleIDsToDelete)
|
||||
}
|
||||
|
||||
// 9. Update search index.
|
||||
if let newArticles = newArticles {
|
||||
searchTable.indexNewArticles(newArticles, database)
|
||||
}
|
||||
if let updatedArticles = updatedArticles {
|
||||
searchTable.indexUpdatedArticles(updatedArticles, database)
|
||||
}
|
||||
|
||||
let articleChanges = ArticleChanges(newArticles: newArticles, updatedArticles: updatedArticles, deletedArticles: articlesToDelete)
|
||||
return articleChanges
|
||||
}
|
||||
|
||||
/// Update articles and save new ones — for sync systems (Feedbin, Feedly, etc.).
|
||||
func update(feedIDsAndItems: [String: Set<ParsedItem>], read: Bool, database: FMDatabase) -> ArticleChanges {
|
||||
|
||||
precondition(retentionStyle == .syncSystem)
|
||||
|
||||
if feedIDsAndItems.isEmpty {
|
||||
return ArticleChanges()
|
||||
}
|
||||
|
||||
// 1. Ensure statuses for all the incoming articles.
|
||||
// 2. Create incoming articles with parsedItems.
|
||||
// 3. Ignore incoming articles that are (!starred and read 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 what’s changed.
|
||||
// 7. Call back with new and updated Articles.
|
||||
// 8. Update search index.
|
||||
|
||||
var articleIDs = Set<String>()
|
||||
for (_, parsedItems) in feedIDsAndItems {
|
||||
articleIDs.formUnion(parsedItems.articleIDs())
|
||||
}
|
||||
|
||||
let (statusesDictionary, _) = statusesTable.ensureStatusesForArticleIDs(articleIDs, read, database) //1
|
||||
assert(statusesDictionary.count == articleIDs.count)
|
||||
|
||||
let allIncomingArticles = Article.articlesWithFeedIDsAndItems(feedIDsAndItems, accountID, statusesDictionary) //2
|
||||
if allIncomingArticles.isEmpty {
|
||||
return ArticleChanges()
|
||||
}
|
||||
|
||||
let incomingArticles = filterIncomingArticles(allIncomingArticles) //3
|
||||
if incomingArticles.isEmpty {
|
||||
return ArticleChanges()
|
||||
}
|
||||
|
||||
let incomingArticleIDs = incomingArticles.articleIDs()
|
||||
let fetchedArticles = articles(articleIDs: incomingArticleIDs, database: database) //4
|
||||
let fetchedArticlesDictionary = fetchedArticles.dictionary()
|
||||
|
||||
let newArticles = findAndSaveNewArticles(incomingArticles, fetchedArticlesDictionary, database) //5
|
||||
let updatedArticles = findAndSaveUpdatedArticles(incomingArticles, fetchedArticlesDictionary, database) //6
|
||||
|
||||
addArticlesToCache(newArticles)
|
||||
addArticlesToCache(updatedArticles)
|
||||
|
||||
// 8. Update search index.
|
||||
if let newArticles = newArticles {
|
||||
searchTable.indexNewArticles(newArticles, database)
|
||||
}
|
||||
if let updatedArticles = updatedArticles {
|
||||
searchTable.indexUpdatedArticles(updatedArticles, database)
|
||||
}
|
||||
|
||||
let articleChanges = ArticleChanges(newArticles: newArticles, updatedArticles: updatedArticles, deletedArticles: nil)
|
||||
return articleChanges
|
||||
}
|
||||
|
||||
/// Delete articles
|
||||
func delete(articleIDs: Set<String>, database: FMDatabase) {
|
||||
|
||||
database.deleteRowsWhere(key: DatabaseKey.articleID, equalsAnyValue: Array(articleIDs), tableName: name)
|
||||
}
|
||||
|
||||
// MARK: - Status
|
||||
|
||||
/// Fetch the articleIDs of unread articles.
|
||||
func unreadArticleIDs(database: FMDatabase) -> Set<String>? {
|
||||
|
||||
statusesTable.articleIDs(key: .read, value: false, database: database)
|
||||
}
|
||||
|
||||
func starredArticleIDs(database: FMDatabase) -> Set<String>? {
|
||||
|
||||
statusesTable.articleIDs(key: .starred, value: true, database: database)
|
||||
}
|
||||
|
||||
func articleIDsForStatusesWithoutArticlesNewerThanCutoffDate(database: FMDatabase) -> Set<String>? {
|
||||
|
||||
statusesTable.articleIDsForStatusesWithoutArticlesNewerThan(cutoffDate: articleCutoffDate, database: database)
|
||||
}
|
||||
|
||||
func mark(articles: Set<Article>, statusKey: ArticleStatus.Key, flag: Bool, database: FMDatabase) -> Set<ArticleStatus>? {
|
||||
|
||||
let statuses = statusesTable.mark(articles.statuses(), statusKey, flag, database)
|
||||
return statuses
|
||||
}
|
||||
|
||||
func mark(articleIDs: Set<String>, statusKey: ArticleStatus.Key, flag: Bool, database: FMDatabase) {
|
||||
|
||||
statusesTable.mark(articleIDs, statusKey, flag, database)
|
||||
}
|
||||
|
||||
/// Create statuses for specified articleIDs. For existing statuses, don’t do anything.
|
||||
/// For newly-created statuses, mark them as read and not-starred.
|
||||
func createStatusesIfNeeded(articleIDs: Set<String>, database: FMDatabase) {
|
||||
|
||||
statusesTable.ensureStatusesForArticleIDs(articleIDs, true, database)
|
||||
}
|
||||
|
||||
// MARK: - Indexing
|
||||
|
||||
/// Returns true if it indexed >0 articles. Keep calling until it returns false.
|
||||
func indexUnindexedArticles(database: FMDatabase) -> Bool {
|
||||
|
||||
let sql = "select articleID from articles where searchRowID is null limit 500;"
|
||||
guard let resultSet = database.executeQuery(sql, withArgumentsIn: nil) else {
|
||||
return false
|
||||
}
|
||||
let articleIDs = resultSet.mapToSet{ $0.string(forColumn: DatabaseKey.articleID) }
|
||||
if articleIDs.isEmpty {
|
||||
return false
|
||||
}
|
||||
|
||||
searchTable.ensureIndexedArticles(articleIDs: articleIDs, database: database)
|
||||
return true
|
||||
}
|
||||
|
||||
// MARK: - Caches
|
||||
|
||||
func emptyCaches() {
|
||||
|
||||
articlesCache = [String: Article]()
|
||||
}
|
||||
|
||||
// MARK: - Cleanup
|
||||
|
||||
/// Delete articles that we won’t show in the UI any longer
|
||||
/// — their arrival date is before our 90-day recency window;
|
||||
/// they are read; they are not starred.
|
||||
///
|
||||
/// Because deleting articles might block the database for too long,
|
||||
/// we do this in a careful way: delete articles older than a year,
|
||||
/// check to see how much time has passed, then decide whether or not to continue.
|
||||
/// Repeat for successively more-recent dates.
|
||||
func deleteOldArticles(database: FMDatabase) {
|
||||
|
||||
precondition(retentionStyle == .syncSystem)
|
||||
|
||||
func deleteOldArticles(cutoffDate: Date) {
|
||||
let sql = "delete from articles where articleID in (select articleID from articles natural join statuses where dateArrived<? and read=1 and starred=0);"
|
||||
let parameters = [cutoffDate] as [Any]
|
||||
database.executeUpdateInTransaction(sql, withArgumentsIn: parameters)
|
||||
}
|
||||
|
||||
let startTime = Date()
|
||||
func tooMuchTimeHasPassed() -> Bool {
|
||||
let timeElapsed = Date().timeIntervalSince(startTime)
|
||||
return timeElapsed > 2.0
|
||||
}
|
||||
|
||||
let dayIntervals = [365, 300, 225, 150]
|
||||
for dayInterval in dayIntervals {
|
||||
deleteOldArticles(cutoffDate: startTime.bySubtracting(days: dayInterval))
|
||||
if tooMuchTimeHasPassed() {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
deleteOldArticles(cutoffDate: self.articleCutoffDate)
|
||||
}
|
||||
|
||||
func deleteArticlesNotInSubscribedToFeedIDs(_ feedIDs: Set<String>, database: FMDatabase) {
|
||||
|
||||
if feedIDs.isEmpty {
|
||||
return
|
||||
}
|
||||
|
||||
let placeholders = NSString.rs_SQLValueList(withPlaceholders: UInt(feedIDs.count))!
|
||||
let sql = "select articleID from articles where feedID not in \(placeholders);"
|
||||
let parameters = Array(feedIDs) as [Any]
|
||||
guard let resultSet = database.executeQuery(sql, withArgumentsIn: parameters) else {
|
||||
return
|
||||
}
|
||||
|
||||
let articleIDs = resultSet.mapToSet{ $0.string(forColumn: DatabaseKey.articleID) }
|
||||
if articleIDs.isEmpty {
|
||||
return
|
||||
}
|
||||
|
||||
removeArticles(articleIDs, database)
|
||||
statusesTable.removeStatuses(articleIDs, database)
|
||||
}
|
||||
|
||||
func deleteOldStatuses(database: FMDatabase) {
|
||||
|
||||
let sql: String
|
||||
let cutoffDate: Date
|
||||
|
||||
switch self.retentionStyle {
|
||||
case .syncSystem:
|
||||
sql = "delete from statuses where dateArrived<? and read=1 and starred=0 and articleID not in (select articleID from articles);"
|
||||
cutoffDate = Date().bySubtracting(days: 180)
|
||||
case .feedBased:
|
||||
sql = "delete from statuses where dateArrived<? and starred=0 and articleID not in (select articleID from articles);"
|
||||
cutoffDate = Date().bySubtracting(days: 30)
|
||||
}
|
||||
|
||||
let parameters = [cutoffDate] as [Any]
|
||||
database.executeUpdateInTransaction(sql, withArgumentsIn: parameters)
|
||||
}
|
||||
}
|
||||
|
||||
private extension ArticlesTable {
|
||||
|
||||
func fetchArticlesWithWhereClause(_ database: FMDatabase, whereClause: String, parameters: [AnyObject]) -> Set<Article> {
|
||||
|
||||
let sql = "select * from articles natural join statuses where \(whereClause);"
|
||||
return articlesWithSQL(sql, parameters, database)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
func articlesWithResultSet(_ resultSet: FMResultSet, _ database: FMDatabase) -> Set<Article> {
|
||||
|
||||
var cachedArticles = Set<Article>()
|
||||
var fetchedArticles = Set<Article>()
|
||||
|
||||
while resultSet.next() {
|
||||
|
||||
guard let articleID = resultSet.string(forColumn: DatabaseKey.articleID) else {
|
||||
assertionFailure("Expected articleID.")
|
||||
continue
|
||||
}
|
||||
|
||||
if let article = articlesCache[articleID] {
|
||||
cachedArticles.insert(article)
|
||||
continue
|
||||
}
|
||||
|
||||
// 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, articleID: articleID) else {
|
||||
assertionFailure("Expected status.")
|
||||
continue
|
||||
}
|
||||
|
||||
guard let article = Article(accountID: accountID, row: resultSet, status: status) else {
|
||||
continue
|
||||
}
|
||||
fetchedArticles.insert(article)
|
||||
}
|
||||
resultSet.close()
|
||||
|
||||
if fetchedArticles.isEmpty {
|
||||
return cachedArticles
|
||||
}
|
||||
|
||||
// Fetch authors for non-cached articles. (Articles from the cache already have authors.)
|
||||
let fetchedArticleIDs = fetchedArticles.articleIDs()
|
||||
let authorsMap = authorsLookupTable.fetchRelatedObjects(for: fetchedArticleIDs, in: database)
|
||||
let articlesWithFetchedAuthors = fetchedArticles.map { (article) -> Article in
|
||||
if let authors = authorsMap?.authors(for: article.articleID) {
|
||||
return article.byAdding(authors)
|
||||
}
|
||||
return article
|
||||
}
|
||||
|
||||
// Add fetchedArticles to cache, now that they have attached authors.
|
||||
for article in articlesWithFetchedAuthors {
|
||||
articlesCache[article.articleID] = article
|
||||
}
|
||||
|
||||
return cachedArticles.union(articlesWithFetchedAuthors)
|
||||
}
|
||||
|
||||
func fetchArticlesSince(feedIDs: Set<String>, cutoffDate: Date, limit: Int?, database: FMDatabase) -> Set<Article> {
|
||||
|
||||
// select * from articles natural join statuses where feedID in ('http://ranchero.com/xml/rss.xml') and (datePublished > ? || (datePublished is null and dateArrived > ?)
|
||||
//
|
||||
// datePublished may be nil, so we fall back to dateArrived.
|
||||
|
||||
if feedIDs.isEmpty {
|
||||
return Set<Article>()
|
||||
}
|
||||
|
||||
let parameters = feedIDs.map { $0 as AnyObject } + [cutoffDate as AnyObject, cutoffDate as AnyObject]
|
||||
let placeholders = NSString.rs_SQLValueList(withPlaceholders: UInt(feedIDs.count))!
|
||||
|
||||
var whereClause = "feedID in \(placeholders) and (datePublished > ? or (datePublished is null and dateArrived > ?))"
|
||||
if let limit = limit {
|
||||
whereClause.append(" order by coalesce(datePublished, dateModified, dateArrived) desc limit \(limit)")
|
||||
}
|
||||
|
||||
return fetchArticlesWithWhereClause(database, whereClause: whereClause, parameters: parameters)
|
||||
}
|
||||
|
||||
func fetchArticlesMatching(_ searchString: String, _ database: FMDatabase) -> Set<Article> {
|
||||
|
||||
let sql = "select rowid from search where search match ?;"
|
||||
let sqlSearchString = sqliteSearchString(with: searchString)
|
||||
let searchStringParameters = [sqlSearchString]
|
||||
|
||||
guard let resultSet = database.executeQuery(sql, withArgumentsIn: searchStringParameters) else {
|
||||
return Set<Article>()
|
||||
}
|
||||
|
||||
let searchRowIDs = resultSet.mapToSet { $0.longLongInt(forColumnIndex: 0) }
|
||||
if searchRowIDs.isEmpty {
|
||||
return Set<Article>()
|
||||
}
|
||||
|
||||
let placeholders = NSString.rs_SQLValueList(withPlaceholders: UInt(searchRowIDs.count))!
|
||||
let whereClause = "searchRowID in \(placeholders)"
|
||||
let parameters: [AnyObject] = Array(searchRowIDs) as [AnyObject]
|
||||
|
||||
return fetchArticlesWithWhereClause(database, whereClause: whereClause, parameters: parameters)
|
||||
}
|
||||
|
||||
func sqliteSearchString(with searchString: String) -> String {
|
||||
var s = ""
|
||||
searchString.enumerateSubstrings(in: searchString.startIndex..<searchString.endIndex, options: .byWords) { (word, range, enclosingRange, stop) in
|
||||
guard let word = word else {
|
||||
return
|
||||
}
|
||||
s += word
|
||||
if word != "AND" && word != "OR" {
|
||||
s += "*"
|
||||
}
|
||||
s += " "
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func removeArticles(_ articleIDs: Set<String>, _ database: FMDatabase) {
|
||||
|
||||
database.deleteRowsWhere(key: DatabaseKey.articleID, equalsAnyValue: Array(articleIDs), tableName: name)
|
||||
}
|
||||
|
||||
// MARK: - Cache
|
||||
|
||||
func addArticlesToCache(_ articles: Set<Article>?) {
|
||||
|
||||
guard let articles = articles else {
|
||||
return
|
||||
}
|
||||
|
||||
for article in articles {
|
||||
articlesCache[article.articleID] = article
|
||||
}
|
||||
}
|
||||
|
||||
func removeArticleIDsFromCache(_ articleIDs: Set<String>) {
|
||||
|
||||
for articleID in articleIDs {
|
||||
articlesCache[articleID] = nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Saving New Articles
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
saveNewArticles(newArticles, database)
|
||||
return newArticles
|
||||
}
|
||||
|
||||
func saveNewArticles(_ articles: Set<Article>, _ database: FMDatabase) {
|
||||
|
||||
saveRelatedObjectsForNewArticles(articles, database)
|
||||
|
||||
if let databaseDictionaries = articles.databaseDictionaries() {
|
||||
database.insertRows(databaseDictionaries, insertType: .orReplace, tableName: name)
|
||||
}
|
||||
}
|
||||
|
||||
func saveRelatedObjectsForNewArticles(_ articles: Set<Article>, _ database: FMDatabase) {
|
||||
|
||||
let databaseObjects = articles.databaseObjects()
|
||||
authorsLookupTable.saveRelatedObjects(for: databaseObjects, in: database)
|
||||
}
|
||||
|
||||
// MARK: - Updating 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.authors, updatedArticles, fetchedArticles, authorsLookupTable, database)
|
||||
}
|
||||
|
||||
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 {
|
||||
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.
|
||||
|
||||
guard let fetchedArticle = fetchedArticles[updatedArticle.articleID] else {
|
||||
assertionFailure("Expected to find matching fetched article.");
|
||||
saveNewArticles(Set([updatedArticle]), database)
|
||||
return
|
||||
}
|
||||
guard let changesDictionary = updatedArticle.changesFrom(fetchedArticle), changesDictionary.count > 0 else {
|
||||
// Not unexpected. There may be no changes.
|
||||
return
|
||||
}
|
||||
|
||||
database.updateRowsWithDictionary(changesDictionary, whereKey: DatabaseKey.articleID, equals: updatedArticle.articleID, tableName: name)
|
||||
}
|
||||
|
||||
func articleIsIgnorable(_ article: Article) -> Bool {
|
||||
|
||||
if article.status.starred || !article.status.read {
|
||||
return false
|
||||
}
|
||||
return article.status.dateArrived < articleCutoffDate
|
||||
}
|
||||
|
||||
func filterIncomingArticles(_ articles: Set<Article>) -> Set<Article> {
|
||||
|
||||
// Drop Articles that we can ignore.
|
||||
|
||||
precondition(retentionStyle == .syncSystem)
|
||||
|
||||
return Set(articles.filter{ !articleIsIgnorable($0) })
|
||||
}
|
||||
}
|
||||
|
||||
private extension Set where Element == ParsedItem {
|
||||
|
||||
func articleIDs() -> Set<String> {
|
||||
|
||||
Set<String>(map { $0.articleID })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
//
|
||||
// AuthorsTable.swift
|
||||
// NetNewsWire
|
||||
//
|
||||
// Created by Brent Simmons on 7/13/17.
|
||||
// Copyright © 2017 Ranchero Software. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Database
|
||||
import Articles
|
||||
import FMDB
|
||||
|
||||
// article->authors is a many-to-many relationship.
|
||||
// There’s a lookup table relating authorID and articleID.
|
||||
//
|
||||
// CREATE TABLE if not EXISTS authors (authorID TEXT NOT NULL PRIMARY KEY, name TEXT, url TEXT, avatarURL TEXT, emailAddress TEXT);
|
||||
// CREATE TABLE if not EXISTS authorLookup (authorID TEXT NOT NULL, articleID TEXT NOT NULL, PRIMARY KEY(authorID, articleID));
|
||||
|
||||
|
||||
final class AuthorsTable: DatabaseRelatedObjectsTable {
|
||||
|
||||
let name = DatabaseTableName.authors
|
||||
let databaseIDKey = DatabaseKey.authorID
|
||||
var cache = DatabaseObjectCache()
|
||||
|
||||
// MARK: - DatabaseRelatedObjectsTable
|
||||
|
||||
func objectWithRow(_ row: FMResultSet) -> DatabaseObject? {
|
||||
if let author = Author(row: row) {
|
||||
return author as DatabaseObject
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
//
|
||||
// Keys.swift
|
||||
// NetNewsWire
|
||||
//
|
||||
// Created by Brent Simmons on 7/3/17.
|
||||
// Copyright © 2017 Ranchero Software. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
// MARK: - Database structure
|
||||
|
||||
struct DatabaseTableName {
|
||||
|
||||
static let articles = "articles"
|
||||
static let authors = "authors"
|
||||
static let authorsLookup = "authorsLookup"
|
||||
static let statuses = "statuses"
|
||||
static let search = "search"
|
||||
}
|
||||
|
||||
struct DatabaseKey {
|
||||
|
||||
// Shared
|
||||
static let articleID = "articleID"
|
||||
static let url = "url"
|
||||
static let title = "title"
|
||||
|
||||
// Article
|
||||
static let feedID = "feedID"
|
||||
static let uniqueID = "uniqueID"
|
||||
static let contentHTML = "contentHTML"
|
||||
static let contentText = "contentText"
|
||||
static let externalURL = "externalURL"
|
||||
static let summary = "summary"
|
||||
static let imageURL = "imageURL"
|
||||
static let bannerImageURL = "bannerImageURL"
|
||||
static let datePublished = "datePublished"
|
||||
static let dateModified = "dateModified"
|
||||
static let authors = "authors"
|
||||
static let searchRowID = "searchRowID"
|
||||
|
||||
// ArticleStatus
|
||||
static let read = "read"
|
||||
static let starred = "starred"
|
||||
static let dateArrived = "dateArrived"
|
||||
|
||||
// Tag
|
||||
static let tagName = "tagName"
|
||||
|
||||
// Author
|
||||
static let authorID = "authorID"
|
||||
static let name = "name"
|
||||
static let avatarURL = "avatarURL"
|
||||
static let emailAddress = "emailAddress"
|
||||
|
||||
// Search
|
||||
static let body = "body"
|
||||
static let rowID = "rowid"
|
||||
}
|
||||
|
||||
struct RelationshipName {
|
||||
|
||||
static let authors = "authors"
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
//
|
||||
// Article+Database.swift
|
||||
// NetNewsWire
|
||||
//
|
||||
// Created by Brent Simmons on 7/3/17.
|
||||
// Copyright © 2017 Ranchero Software. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Database
|
||||
import Articles
|
||||
import Parser
|
||||
import FMDB
|
||||
|
||||
extension Article {
|
||||
|
||||
init?(accountID: String, row: FMResultSet, status: ArticleStatus) {
|
||||
guard let articleID = row.string(forColumn: DatabaseKey.articleID) else {
|
||||
assertionFailure("Expected articleID.")
|
||||
return nil
|
||||
}
|
||||
guard let feedID = row.string(forColumn: DatabaseKey.feedID) else {
|
||||
assertionFailure("Expected feedID.")
|
||||
return nil
|
||||
}
|
||||
guard let uniqueID = row.string(forColumn: DatabaseKey.uniqueID) else {
|
||||
assertionFailure("Expected uniqueID.")
|
||||
return nil
|
||||
}
|
||||
|
||||
let title = row.string(forColumn: DatabaseKey.title)
|
||||
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 datePublished = row.date(forColumn: DatabaseKey.datePublished)
|
||||
let dateModified = row.date(forColumn: DatabaseKey.dateModified)
|
||||
|
||||
self.init(accountID: accountID, articleID: articleID, feedID: feedID, uniqueID: uniqueID, title: title, contentHTML: contentHTML, contentText: contentText, url: url, externalURL: externalURL, summary: summary, imageURL: imageURL, datePublished: datePublished, dateModified: dateModified, authors: nil, status: status)
|
||||
}
|
||||
|
||||
init(parsedItem: ParsedItem, maximumDateAllowed: Date, accountID: String, feedID: String, status: ArticleStatus) {
|
||||
let authors = Author.authorsWithParsedAuthors(parsedItem.authors)
|
||||
|
||||
// Deal with future datePublished and dateModified dates.
|
||||
var datePublished = parsedItem.datePublished
|
||||
if datePublished == nil {
|
||||
datePublished = parsedItem.dateModified
|
||||
}
|
||||
if datePublished != nil, datePublished! > maximumDateAllowed {
|
||||
datePublished = nil
|
||||
}
|
||||
|
||||
var dateModified = parsedItem.dateModified
|
||||
if dateModified != nil, dateModified! > maximumDateAllowed {
|
||||
dateModified = nil
|
||||
}
|
||||
|
||||
self.init(accountID: accountID, 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, datePublished: datePublished, dateModified: dateModified, authors: authors, status: status)
|
||||
}
|
||||
|
||||
private func addPossibleStringChangeWithKeyPath(_ comparisonKeyPath: KeyPath<Article,String?>, _ otherArticle: Article, _ key: String, _ dictionary: inout DatabaseDictionary) {
|
||||
if self[keyPath: comparisonKeyPath] != otherArticle[keyPath: comparisonKeyPath] {
|
||||
dictionary[key] = self[keyPath: comparisonKeyPath] ?? ""
|
||||
}
|
||||
}
|
||||
|
||||
func byAdding(_ authors: Set<Author>) -> Article {
|
||||
if authors.isEmpty {
|
||||
return self
|
||||
}
|
||||
return Article(accountID: self.accountID, articleID: self.articleID, feedID: self.feedID, uniqueID: self.uniqueID, title: self.title, contentHTML: self.contentHTML, contentText: self.contentText, url: self.rawLink, externalURL: self.rawExternalLink, summary: self.summary, imageURL: self.rawImageLink, datePublished: self.datePublished, dateModified: self.dateModified, authors: authors, status: self.status)
|
||||
}
|
||||
|
||||
func changesFrom(_ existingArticle: Article) -> DatabaseDictionary? {
|
||||
if self == existingArticle {
|
||||
return nil
|
||||
}
|
||||
|
||||
var d = DatabaseDictionary()
|
||||
if uniqueID != existingArticle.uniqueID {
|
||||
d[DatabaseKey.uniqueID] = uniqueID
|
||||
}
|
||||
|
||||
addPossibleStringChangeWithKeyPath(\Article.title, existingArticle, DatabaseKey.title, &d)
|
||||
addPossibleStringChangeWithKeyPath(\Article.contentHTML, existingArticle, DatabaseKey.contentHTML, &d)
|
||||
addPossibleStringChangeWithKeyPath(\Article.contentText, existingArticle, DatabaseKey.contentText, &d)
|
||||
addPossibleStringChangeWithKeyPath(\Article.rawLink, existingArticle, DatabaseKey.url, &d)
|
||||
addPossibleStringChangeWithKeyPath(\Article.rawExternalLink, existingArticle, DatabaseKey.externalURL, &d)
|
||||
addPossibleStringChangeWithKeyPath(\Article.summary, existingArticle, DatabaseKey.summary, &d)
|
||||
addPossibleStringChangeWithKeyPath(\Article.rawImageLink, existingArticle, DatabaseKey.imageURL, &d)
|
||||
|
||||
// If updated versions of dates are nil, and we have existing dates, keep the existing dates.
|
||||
// This is data that’s good to have, and it’s likely that a feed removing dates is doing so in error.
|
||||
if datePublished != existingArticle.datePublished {
|
||||
if let updatedDatePublished = datePublished {
|
||||
d[DatabaseKey.datePublished] = updatedDatePublished
|
||||
}
|
||||
}
|
||||
if dateModified != existingArticle.dateModified {
|
||||
if let updatedDateModified = dateModified {
|
||||
d[DatabaseKey.dateModified] = updatedDateModified
|
||||
}
|
||||
}
|
||||
|
||||
return d.count < 1 ? nil : d
|
||||
}
|
||||
|
||||
private static func _maximumDateAllowed() -> Date {
|
||||
return Date().addingTimeInterval(60 * 60 * 24) // Allow dates up to about 24 hours ahead of now
|
||||
}
|
||||
|
||||
static func articlesWithFeedIDsAndItems(_ feedIDsAndItems: [String: Set<ParsedItem>], _ accountID: String, _ statusesDictionary: [String: ArticleStatus]) -> Set<Article> {
|
||||
let maximumDateAllowed = _maximumDateAllowed()
|
||||
var feedArticles = Set<Article>()
|
||||
for (feedID, parsedItems) in feedIDsAndItems {
|
||||
for parsedItem in parsedItems {
|
||||
let status = statusesDictionary[parsedItem.articleID]!
|
||||
let article = Article(parsedItem: parsedItem, maximumDateAllowed: maximumDateAllowed, accountID: accountID, feedID: feedID, status: status)
|
||||
feedArticles.insert(article)
|
||||
}
|
||||
}
|
||||
return feedArticles
|
||||
}
|
||||
|
||||
static func articlesWithParsedItems(_ parsedItems: Set<ParsedItem>, _ feedID: String, _ accountID: String, _ statusesDictionary: [String: ArticleStatus]) -> Set<Article> {
|
||||
let maximumDateAllowed = _maximumDateAllowed()
|
||||
return Set(parsedItems.map{ Article(parsedItem: $0, maximumDateAllowed: maximumDateAllowed, accountID: accountID, feedID: feedID, status: statusesDictionary[$0.articleID]!) })
|
||||
}
|
||||
}
|
||||
|
||||
extension Article: DatabaseObject {
|
||||
|
||||
public func databaseDictionary() -> DatabaseDictionary? {
|
||||
var d = DatabaseDictionary()
|
||||
|
||||
d[DatabaseKey.articleID] = articleID
|
||||
d[DatabaseKey.feedID] = feedID
|
||||
d[DatabaseKey.uniqueID] = uniqueID
|
||||
|
||||
if let title = title {
|
||||
d[DatabaseKey.title] = title
|
||||
}
|
||||
if let contentHTML = contentHTML {
|
||||
d[DatabaseKey.contentHTML] = contentHTML
|
||||
}
|
||||
if let contentText = contentText {
|
||||
d[DatabaseKey.contentText] = contentText
|
||||
}
|
||||
if let rawLink = rawLink {
|
||||
d[DatabaseKey.url] = rawLink
|
||||
}
|
||||
if let rawExternalLink = rawExternalLink {
|
||||
d[DatabaseKey.externalURL] = rawExternalLink
|
||||
}
|
||||
if let summary = summary {
|
||||
d[DatabaseKey.summary] = summary
|
||||
}
|
||||
if let rawImageLink = rawImageLink {
|
||||
d[DatabaseKey.imageURL] = rawImageLink
|
||||
}
|
||||
if let datePublished = datePublished {
|
||||
d[DatabaseKey.datePublished] = datePublished
|
||||
}
|
||||
if let dateModified = dateModified {
|
||||
d[DatabaseKey.dateModified] = dateModified
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
public var databaseID: String {
|
||||
return articleID
|
||||
}
|
||||
|
||||
public func relatedObjectsWithName(_ name: String) -> [DatabaseObject]? {
|
||||
switch name {
|
||||
case RelationshipName.authors:
|
||||
return databaseObjectArray(with: authors)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private func databaseObjectArray<T: DatabaseObject>(with objects: Set<T>?) -> [DatabaseObject]? {
|
||||
guard let objects = objects else {
|
||||
return nil
|
||||
}
|
||||
return Array(objects)
|
||||
}
|
||||
}
|
||||
|
||||
extension Set where Element == Article {
|
||||
|
||||
func statuses() -> Set<ArticleStatus> {
|
||||
return Set<ArticleStatus>(map { $0.status })
|
||||
}
|
||||
|
||||
func dictionary() -> [String: Article] {
|
||||
var d = [String: Article]()
|
||||
for article in self {
|
||||
d[article.articleID] = article
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
func databaseObjects() -> [DatabaseObject] {
|
||||
return self.map{ $0 as DatabaseObject }
|
||||
}
|
||||
|
||||
func databaseDictionaries() -> [DatabaseDictionary]? {
|
||||
return self.compactMap { $0.databaseDictionary() }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
//
|
||||
// ArticleStatus+Database.swift
|
||||
// NetNewsWire
|
||||
//
|
||||
// Created by Brent Simmons on 7/3/17.
|
||||
// Copyright © 2017 Ranchero Software. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Database
|
||||
import Articles
|
||||
import FMDB
|
||||
|
||||
extension ArticleStatus {
|
||||
|
||||
convenience init(articleID: String, dateArrived: Date, row: FMResultSet) {
|
||||
let read = row.bool(forColumn: DatabaseKey.read)
|
||||
let starred = row.bool(forColumn: DatabaseKey.starred)
|
||||
|
||||
self.init(articleID: articleID, read: read, starred: starred, dateArrived: dateArrived)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension ArticleStatus: DatabaseObject {
|
||||
|
||||
public var databaseID: String {
|
||||
return articleID
|
||||
}
|
||||
|
||||
public func databaseDictionary() -> DatabaseDictionary? {
|
||||
return [DatabaseKey.articleID: articleID, DatabaseKey.read: read, DatabaseKey.starred: starred, DatabaseKey.dateArrived: dateArrived]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
//
|
||||
// Author+Database.swift
|
||||
// NetNewsWire
|
||||
//
|
||||
// Created by Brent Simmons on 7/8/17.
|
||||
// Copyright © 2017 Ranchero Software. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Articles
|
||||
import Database
|
||||
import Parser
|
||||
import FMDB
|
||||
|
||||
// MARK: - DatabaseObject
|
||||
|
||||
extension Author {
|
||||
|
||||
init?(row: FMResultSet) {
|
||||
let authorID = row.string(forColumn: DatabaseKey.authorID)
|
||||
let name = row.string(forColumn: DatabaseKey.name)
|
||||
let url = row.string(forColumn: DatabaseKey.url)
|
||||
let avatarURL = row.string(forColumn: DatabaseKey.avatarURL)
|
||||
let emailAddress = row.string(forColumn: DatabaseKey.emailAddress)
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
public static func authorsWithParsedAuthors(_ parsedAuthors: Set<ParsedAuthor>?) -> Set<Author>? {
|
||||
guard let parsedAuthors = parsedAuthors else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let authors = Set(parsedAuthors.compactMap { Author(parsedAuthor: $0) })
|
||||
return authors.isEmpty ? nil: authors
|
||||
}
|
||||
}
|
||||
|
||||
extension Author: DatabaseObject {
|
||||
|
||||
public var databaseID: String {
|
||||
return authorID
|
||||
}
|
||||
|
||||
public func databaseDictionary() -> DatabaseDictionary? {
|
||||
var d: DatabaseDictionary = [DatabaseKey.authorID: authorID]
|
||||
if let name = name {
|
||||
d[DatabaseKey.name] = name
|
||||
}
|
||||
if let url = url {
|
||||
d[DatabaseKey.url] = url
|
||||
}
|
||||
if let avatarURL = avatarURL {
|
||||
d[DatabaseKey.avatarURL] = avatarURL
|
||||
}
|
||||
if let emailAddress = emailAddress {
|
||||
d[DatabaseKey.emailAddress] = emailAddress
|
||||
}
|
||||
return d
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
//
|
||||
// DatabaseObject+Database.swift
|
||||
// NetNewsWire
|
||||
//
|
||||
// Created by Brent Simmons on 9/13/17.
|
||||
// Copyright © 2017 Ranchero Software. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Database
|
||||
import Articles
|
||||
|
||||
extension Array where Element == DatabaseObject {
|
||||
|
||||
func asAuthors() -> Set<Author>? {
|
||||
let authors = Set(self.map { $0 as! Author })
|
||||
return authors.isEmpty ? nil : authors
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
//
|
||||
// ParsedArticle+Database.swift
|
||||
// NetNewsWire
|
||||
//
|
||||
// Created by Brent Simmons on 9/18/17.
|
||||
// Copyright © 2017 Ranchero Software. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Parser
|
||||
import Articles
|
||||
|
||||
extension ParsedItem {
|
||||
|
||||
var articleID: String {
|
||||
if let s = syncServiceID {
|
||||
return s
|
||||
}
|
||||
// Must be same calculation as for Article.
|
||||
return Article.calculatedArticleID(feedID: feedURL, uniqueID: uniqueID)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
//
|
||||
// RelatedObjectsMap+Database.swift
|
||||
// Database
|
||||
//
|
||||
// Created by Brent Simmons on 9/13/17.
|
||||
// Copyright © 2017 Ranchero Software. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Database
|
||||
import Articles
|
||||
|
||||
extension RelatedObjectsMap {
|
||||
|
||||
func authors(for articleID: String) -> Set<Author>? {
|
||||
if let objects = self[articleID] {
|
||||
return objects.asAuthors()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
//
|
||||
// SearchTable.swift
|
||||
// NetNewsWire
|
||||
//
|
||||
// Created by Brent Simmons on 2/23/19.
|
||||
// Copyright © 2019 Ranchero Software. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Database
|
||||
import Articles
|
||||
import Parser
|
||||
import FMDB
|
||||
|
||||
final class ArticleSearchInfo: Hashable {
|
||||
|
||||
let articleID: String
|
||||
let title: String?
|
||||
let contentHTML: String?
|
||||
let contentText: String?
|
||||
let summary: String?
|
||||
let authorsNames: String?
|
||||
let searchRowID: Int?
|
||||
|
||||
var preferredText: String {
|
||||
if let body = contentHTML, !body.isEmpty {
|
||||
return body
|
||||
}
|
||||
if let body = contentText, !body.isEmpty {
|
||||
return body
|
||||
}
|
||||
return summary ?? ""
|
||||
}
|
||||
|
||||
lazy var bodyForIndex: String = {
|
||||
let s = preferredText.rsparser_stringByDecodingHTMLEntities()
|
||||
let sanitizedBody = s.strippingHTML().collapsingWhitespace
|
||||
|
||||
if let authorsNames = authorsNames {
|
||||
return sanitizedBody.appending(" \(authorsNames)")
|
||||
} else {
|
||||
return sanitizedBody
|
||||
}
|
||||
}()
|
||||
|
||||
init(articleID: String, title: String?, contentHTML: String?, contentText: String?, summary: String?, authorsNames: String?, searchRowID: Int?) {
|
||||
self.articleID = articleID
|
||||
self.title = title
|
||||
self.authorsNames = authorsNames
|
||||
self.contentHTML = contentHTML
|
||||
self.contentText = contentText
|
||||
self.summary = summary
|
||||
self.searchRowID = searchRowID
|
||||
}
|
||||
|
||||
convenience init(article: Article) {
|
||||
let authorsNames: String?
|
||||
if let authors = article.authors {
|
||||
authorsNames = authors.compactMap({ $0.name }).joined(separator: " ")
|
||||
} else {
|
||||
authorsNames = nil
|
||||
}
|
||||
self.init(articleID: article.articleID, title: article.title, contentHTML: article.contentHTML, contentText: article.contentText, summary: article.summary, authorsNames: authorsNames, searchRowID: nil)
|
||||
}
|
||||
|
||||
// MARK: Hashable
|
||||
|
||||
public func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(articleID)
|
||||
}
|
||||
|
||||
// MARK: Equatable
|
||||
|
||||
static func == (lhs: ArticleSearchInfo, rhs: ArticleSearchInfo) -> Bool {
|
||||
return lhs.articleID == rhs.articleID && lhs.title == rhs.title && lhs.contentHTML == rhs.contentHTML && lhs.contentText == rhs.contentText && lhs.summary == rhs.summary && lhs.authorsNames == rhs.authorsNames && lhs.searchRowID == rhs.searchRowID
|
||||
}
|
||||
}
|
||||
|
||||
final class SearchTable {
|
||||
|
||||
let name = DatabaseTableName.search
|
||||
|
||||
/// Add to, or update, the search index for articles with specified IDs.
|
||||
func ensureIndexedArticles(articleIDs: Set<String>, database: FMDatabase) {
|
||||
|
||||
guard let articleSearchInfos = fetchArticleSearchInfos(articleIDs: articleIDs, database: database) else {
|
||||
return
|
||||
}
|
||||
|
||||
let unindexedArticles = articleSearchInfos.filter { $0.searchRowID == nil }
|
||||
performInitialIndexForArticles(unindexedArticles, database)
|
||||
|
||||
let indexedArticles = articleSearchInfos.filter { $0.searchRowID != nil }
|
||||
updateIndexForArticles(indexedArticles, database)
|
||||
}
|
||||
|
||||
/// Index new articles.
|
||||
func indexNewArticles(_ articles: Set<Article>, _ database: FMDatabase) {
|
||||
|
||||
let articleSearchInfos = Set(articles.map{ ArticleSearchInfo(article: $0) })
|
||||
performInitialIndexForArticles(articleSearchInfos, database)
|
||||
}
|
||||
|
||||
/// Index updated articles.
|
||||
func indexUpdatedArticles(_ articles: Set<Article>, _ database: FMDatabase) {
|
||||
|
||||
ensureIndexedArticles(articleIDs: articles.articleIDs(), database: database)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private extension SearchTable {
|
||||
|
||||
func performInitialIndexForArticles(_ articles: Set<ArticleSearchInfo>, _ database: FMDatabase) {
|
||||
|
||||
for article in articles {
|
||||
performInitialIndex(article, database)
|
||||
}
|
||||
}
|
||||
|
||||
func performInitialIndex(_ article: ArticleSearchInfo, _ database: FMDatabase) {
|
||||
|
||||
let rowid = insert(article, database)
|
||||
database.updateRowsWithValue(rowid, valueKey: DatabaseKey.searchRowID, whereKey: DatabaseKey.articleID, equals: article.articleID, tableName: DatabaseTableName.articles)
|
||||
}
|
||||
|
||||
func insert(_ article: ArticleSearchInfo, _ database: FMDatabase) -> Int {
|
||||
|
||||
let rowDictionary: DatabaseDictionary = [DatabaseKey.body: article.bodyForIndex, DatabaseKey.title: article.title ?? ""]
|
||||
database.insertRow(rowDictionary, insertType: .normal, tableName: name)
|
||||
return Int(database.lastInsertRowId())
|
||||
}
|
||||
|
||||
private struct SearchInfo: Hashable {
|
||||
let rowID: Int
|
||||
let title: String
|
||||
let body: String
|
||||
|
||||
init(row: FMResultSet) {
|
||||
self.rowID = Int(row.longLongInt(forColumn: DatabaseKey.rowID))
|
||||
self.title = row.string(forColumn: DatabaseKey.title) ?? ""
|
||||
self.body = row.string(forColumn: DatabaseKey.body) ?? ""
|
||||
}
|
||||
|
||||
// MARK: Hashable
|
||||
|
||||
public func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(rowID)
|
||||
}
|
||||
}
|
||||
|
||||
func updateIndexForArticles(_ articles: Set<ArticleSearchInfo>, _ database: FMDatabase) {
|
||||
if articles.isEmpty {
|
||||
return
|
||||
}
|
||||
guard let searchInfos = fetchSearchInfos(articles, database) else {
|
||||
// The articles that get here have a non-nil searchRowID, and we should have found rows in the search table for them.
|
||||
// But we didn’t. Recover by doing an initial index.
|
||||
performInitialIndexForArticles(articles, database)
|
||||
return
|
||||
}
|
||||
let groupedSearchInfos = Dictionary(grouping: searchInfos, by: { $0.rowID })
|
||||
let searchInfosDictionary = groupedSearchInfos.mapValues { $0.first! }
|
||||
|
||||
for article in articles {
|
||||
updateIndexForArticle(article, searchInfosDictionary, database)
|
||||
}
|
||||
}
|
||||
|
||||
private func updateIndexForArticle(_ article: ArticleSearchInfo, _ searchInfosDictionary: [Int: SearchInfo], _ database: FMDatabase) {
|
||||
guard let searchRowID = article.searchRowID else {
|
||||
assertionFailure("Expected article.searchRowID, got nil")
|
||||
return
|
||||
}
|
||||
guard let searchInfo: SearchInfo = searchInfosDictionary[searchRowID] else {
|
||||
// Shouldn’t happen. The article has a searchRowID, but we didn’t find that row in the search table.
|
||||
// Easy to recover from: just do an initial index, and all’s well.
|
||||
performInitialIndex(article, database)
|
||||
return
|
||||
}
|
||||
|
||||
let title = article.title ?? ""
|
||||
if title == searchInfo.title && article.bodyForIndex == searchInfo.body {
|
||||
return
|
||||
}
|
||||
|
||||
var updateDictionary = DatabaseDictionary()
|
||||
if title != searchInfo.title {
|
||||
updateDictionary[DatabaseKey.title] = title
|
||||
}
|
||||
if article.bodyForIndex != searchInfo.body {
|
||||
updateDictionary[DatabaseKey.body] = article.bodyForIndex
|
||||
}
|
||||
database.updateRowsWithDictionary(updateDictionary, whereKey: DatabaseKey.rowID, equals: searchInfo.rowID, tableName: name)
|
||||
}
|
||||
|
||||
private func fetchSearchInfos(_ articles: Set<ArticleSearchInfo>, _ database: FMDatabase) -> Set<SearchInfo>? {
|
||||
let searchRowIDs = articles.compactMap { $0.searchRowID }
|
||||
guard !searchRowIDs.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
let placeholders = NSString.rs_SQLValueList(withPlaceholders: UInt(searchRowIDs.count))!
|
||||
let sql = "select rowid, title, body from \(name) where rowid in \(placeholders);"
|
||||
guard let resultSet = database.executeQuery(sql, withArgumentsIn: searchRowIDs) else {
|
||||
return nil
|
||||
}
|
||||
return resultSet.mapToSet { SearchInfo(row: $0) }
|
||||
}
|
||||
|
||||
func fetchArticleSearchInfos(articleIDs: Set<String>, database: FMDatabase) -> Set<ArticleSearchInfo>? {
|
||||
|
||||
let parameters = articleIDs.map { $0 as AnyObject }
|
||||
let placeholders = NSString.rs_SQLValueList(withPlaceholders: UInt(articleIDs.count))!
|
||||
|
||||
guard let resultSet = database.executeQuery(articleSearchInfosQuery(with: placeholders), withArgumentsIn: parameters) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let articleSearchInfo = resultSet.mapToSet { (row) -> ArticleSearchInfo? in
|
||||
let articleID = row.string(forColumn: DatabaseKey.articleID)!
|
||||
let title = row.string(forColumn: DatabaseKey.title)
|
||||
let contentHTML = row.string(forColumn: DatabaseKey.contentHTML)
|
||||
let contentText = row.string(forColumn: DatabaseKey.contentText)
|
||||
let summary = row.string(forColumn: DatabaseKey.summary)
|
||||
let authorsNames = row.string(forColumn: DatabaseKey.authors)
|
||||
|
||||
let searchRowIDObject = row.object(forColumnName: DatabaseKey.searchRowID)
|
||||
var searchRowID: Int? = nil
|
||||
if searchRowIDObject != nil && !(searchRowIDObject is NSNull) {
|
||||
searchRowID = Int(row.longLongInt(forColumn: DatabaseKey.searchRowID))
|
||||
}
|
||||
|
||||
return ArticleSearchInfo(articleID: articleID, title: title, contentHTML: contentHTML, contentText: contentText, summary: summary, authorsNames: authorsNames, searchRowID: searchRowID)
|
||||
}
|
||||
|
||||
return articleSearchInfo
|
||||
}
|
||||
|
||||
private func articleSearchInfosQuery(with placeholders: String) -> String {
|
||||
return """
|
||||
SELECT
|
||||
art.articleID,
|
||||
art.title,
|
||||
art.contentHTML,
|
||||
art.contentText,
|
||||
art.summary,
|
||||
art.searchRowID,
|
||||
(SELECT GROUP_CONCAT(name, ' ')
|
||||
FROM authorsLookup as autL
|
||||
JOIN authors as aut ON autL.authorID = aut.authorID
|
||||
WHERE art.articleID = autL.articleID
|
||||
GROUP BY autl.articleID) as authors
|
||||
FROM articles as art
|
||||
WHERE articleID in \(placeholders);
|
||||
"""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
//
|
||||
// StatusesTable.swift
|
||||
// NetNewsWire
|
||||
//
|
||||
// Created by Brent Simmons on 5/8/16.
|
||||
// Copyright © 2016 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Database
|
||||
import Articles
|
||||
import FMDB
|
||||
|
||||
// Article->ArticleStatus is a to-one relationship.
|
||||
//
|
||||
// CREATE TABLE if not EXISTS statuses (articleID TEXT NOT NULL PRIMARY KEY, read BOOL NOT NULL DEFAULT 0, starred BOOL NOT NULL DEFAULT 0, dateArrived DATE NOT NULL DEFAULT 0);
|
||||
|
||||
final class StatusesTable {
|
||||
|
||||
let name = DatabaseTableName.statuses
|
||||
private let cache = StatusCache()
|
||||
|
||||
// MARK: - Creating/Updating
|
||||
|
||||
@discardableResult
|
||||
func ensureStatusesForArticleIDs(_ articleIDs: Set<String>, _ read: Bool, _ database: FMDatabase) -> ([String: ArticleStatus], Set<String>) {
|
||||
|
||||
#if DEBUG
|
||||
// Check for missing statuses — this asserts that all the passed-in articleIDs exist in the statuses table.
|
||||
defer {
|
||||
if let resultSet = database.selectRowsWhere(key: DatabaseKey.articleID, equalsAnyValue: Array(articleIDs), tableName: name) {
|
||||
let fetchedStatuses = resultSet.mapToSet(statusWithRow)
|
||||
let fetchedArticleIDs = Set(fetchedStatuses.map{ $0.articleID })
|
||||
assert(fetchedArticleIDs == articleIDs)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
// Check cache.
|
||||
let articleIDsMissingCachedStatus = articleIDsWithNoCachedStatus(articleIDs)
|
||||
if articleIDsMissingCachedStatus.isEmpty {
|
||||
return (statusesDictionary(articleIDs), Set<String>())
|
||||
}
|
||||
|
||||
// Check database.
|
||||
fetchAndCacheStatusesForArticleIDs(articleIDsMissingCachedStatus, database)
|
||||
|
||||
let articleIDsNeedingStatus = self.articleIDsWithNoCachedStatus(articleIDs)
|
||||
if !articleIDsNeedingStatus.isEmpty {
|
||||
// Create new statuses.
|
||||
self.createAndSaveStatusesForArticleIDs(articleIDsNeedingStatus, read, database)
|
||||
}
|
||||
|
||||
return (statusesDictionary(articleIDs), articleIDsNeedingStatus)
|
||||
}
|
||||
|
||||
// MARK: - Marking
|
||||
|
||||
@discardableResult
|
||||
func mark(_ statuses: Set<ArticleStatus>, _ statusKey: ArticleStatus.Key, _ flag: Bool, _ database: FMDatabase) -> Set<ArticleStatus>? {
|
||||
// Sets flag in both memory and in database.
|
||||
|
||||
var updatedStatuses = Set<ArticleStatus>()
|
||||
|
||||
for status in statuses {
|
||||
if status.boolStatus(forKey: statusKey) == flag {
|
||||
continue
|
||||
}
|
||||
status.setBoolStatus(flag, forKey: statusKey)
|
||||
updatedStatuses.insert(status)
|
||||
}
|
||||
|
||||
if updatedStatuses.isEmpty {
|
||||
return nil
|
||||
}
|
||||
let articleIDs = updatedStatuses.articleIDs()
|
||||
|
||||
self.markArticleIDs(articleIDs, statusKey, flag, database)
|
||||
|
||||
return updatedStatuses
|
||||
}
|
||||
|
||||
func mark(_ articleIDs: Set<String>, _ statusKey: ArticleStatus.Key, _ flag: Bool, _ database: FMDatabase) {
|
||||
let (statusesDictionary, _) = ensureStatusesForArticleIDs(articleIDs, flag, database)
|
||||
let statuses = Set(statusesDictionary.values)
|
||||
mark(statuses, statusKey, flag, database)
|
||||
}
|
||||
|
||||
// MARK: - Fetching
|
||||
|
||||
func articleIDs(key: ArticleStatus.Key, value: Bool, database: FMDatabase) -> Set<String>? {
|
||||
|
||||
var sql = "select articleID from statuses where \(key.rawValue)="
|
||||
sql += value ? "1" : "0"
|
||||
sql += ";"
|
||||
|
||||
guard let resultSet = database.executeQuery(sql, withArgumentsIn: nil) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let articleIDs = resultSet.mapToSet{ $0.string(forColumnIndex: 0) }
|
||||
return articleIDs
|
||||
}
|
||||
|
||||
|
||||
func articleIDsForStatusesWithoutArticlesNewerThan(cutoffDate: Date, database: FMDatabase) -> Set<String>? {
|
||||
|
||||
let sql = "select articleID from statuses s where (starred=1 or dateArrived>?) and not exists (select 1 from articles a where a.articleID = s.articleID);"
|
||||
guard let resultSet = database.executeQuery(sql, withArgumentsIn: [cutoffDate]) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let articleIDs = resultSet.mapToSet(articleIDWithRow)
|
||||
return articleIDs
|
||||
}
|
||||
|
||||
func articleIDWithRow(_ row: FMResultSet) -> String? {
|
||||
return row.string(forColumn: DatabaseKey.articleID)
|
||||
}
|
||||
|
||||
func statusWithRow(_ row: FMResultSet) -> ArticleStatus? {
|
||||
guard let articleID = row.string(forColumn: DatabaseKey.articleID) else {
|
||||
return nil
|
||||
}
|
||||
return statusWithRow(row, articleID: articleID)
|
||||
}
|
||||
|
||||
func statusWithRow(_ row: FMResultSet, articleID: String) ->ArticleStatus? {
|
||||
if let cachedStatus = cache[articleID] {
|
||||
return cachedStatus
|
||||
}
|
||||
|
||||
guard let dateArrived = row.date(forColumn: DatabaseKey.dateArrived) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let articleStatus = ArticleStatus(articleID: articleID, dateArrived: dateArrived, row: row)
|
||||
cache.addStatusIfNotCached(articleStatus)
|
||||
|
||||
return articleStatus
|
||||
}
|
||||
|
||||
func statusesDictionary(_ articleIDs: Set<String>) -> [String: ArticleStatus] {
|
||||
var d = [String: ArticleStatus]()
|
||||
|
||||
for articleID in articleIDs {
|
||||
if let articleStatus = cache[articleID] {
|
||||
d[articleID] = articleStatus
|
||||
}
|
||||
}
|
||||
|
||||
return d
|
||||
}
|
||||
|
||||
// MARK: - Cleanup
|
||||
|
||||
func removeStatuses(_ articleIDs: Set<String>, _ database: FMDatabase) {
|
||||
|
||||
database.deleteRowsWhere(key: DatabaseKey.articleID, equalsAnyValue: Array(articleIDs), tableName: name)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private extension StatusesTable {
|
||||
|
||||
// MARK: - Cache
|
||||
|
||||
func articleIDsWithNoCachedStatus(_ articleIDs: Set<String>) -> Set<String> {
|
||||
return Set(articleIDs.filter { cache[$0] == nil })
|
||||
}
|
||||
|
||||
// MARK: - Creating
|
||||
|
||||
func saveStatuses(_ statuses: Set<ArticleStatus>, _ database: FMDatabase) {
|
||||
let statusArray = statuses.map { $0.databaseDictionary()! }
|
||||
database.insertRows(statusArray, insertType: .orIgnore, tableName: name)
|
||||
}
|
||||
|
||||
func createAndSaveStatusesForArticleIDs(_ articleIDs: Set<String>, _ read: Bool, _ database: FMDatabase) {
|
||||
let now = Date()
|
||||
let statuses = Set(articleIDs.map { ArticleStatus(articleID: $0, read: read, dateArrived: now) })
|
||||
cache.addIfNotCached(statuses)
|
||||
|
||||
saveStatuses(statuses, database)
|
||||
}
|
||||
|
||||
func fetchAndCacheStatusesForArticleIDs(_ articleIDs: Set<String>, _ database: FMDatabase) {
|
||||
guard let resultSet = database.selectRowsWhere(key: DatabaseKey.articleID, equalsAnyValue: Array(articleIDs), tableName: name) else {
|
||||
return
|
||||
}
|
||||
|
||||
let statuses = resultSet.mapToSet(self.statusWithRow)
|
||||
self.cache.addIfNotCached(statuses)
|
||||
}
|
||||
|
||||
// MARK: - Marking
|
||||
|
||||
func markArticleIDs(_ articleIDs: Set<String>, _ statusKey: ArticleStatus.Key, _ flag: Bool, _ database: FMDatabase) {
|
||||
|
||||
database.updateRowsWithValue(NSNumber(value: flag), valueKey: statusKey.rawValue, whereKey: DatabaseKey.articleID, equalsAnyValue: Array(articleIDs), tableName: name)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: -
|
||||
|
||||
private final class StatusCache {
|
||||
|
||||
// Serial database queue only.
|
||||
|
||||
var dictionary = [String: ArticleStatus]()
|
||||
var cachedStatuses: Set<ArticleStatus> {
|
||||
return Set(dictionary.values)
|
||||
}
|
||||
|
||||
func add(_ statuses: Set<ArticleStatus>) {
|
||||
// Replaces any cached statuses.
|
||||
for status in statuses {
|
||||
self[status.articleID] = status
|
||||
}
|
||||
}
|
||||
|
||||
func addStatusIfNotCached(_ status: ArticleStatus) {
|
||||
addIfNotCached(Set([status]))
|
||||
}
|
||||
|
||||
func addIfNotCached(_ statuses: Set<ArticleStatus>) {
|
||||
// Does not replace already cached statuses.
|
||||
|
||||
for status in statuses {
|
||||
let articleID = status.articleID
|
||||
if let _ = self[articleID] {
|
||||
continue
|
||||
}
|
||||
self[articleID] = status
|
||||
}
|
||||
}
|
||||
|
||||
subscript(_ articleID: String) -> ArticleStatus? {
|
||||
get {
|
||||
return dictionary[articleID]
|
||||
}
|
||||
set {
|
||||
dictionary[articleID] = newValue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user