Move local modules into a folder named Modules.

This commit is contained in:
Brent Simmons
2024-07-06 21:07:05 -07:00
parent 14bcef0f9a
commit d50b5818ac
491 changed files with 76 additions and 52 deletions

View File

@@ -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 dont. 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, dont 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 didnt 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()
}
}
}
}

View File

@@ -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 whats 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 whats 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, dont 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 wont 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 })
}
}

View File

@@ -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.
// Theres 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
}
}

View File

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

View File

@@ -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 thats good to have, and its 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() }
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 didnt. 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 {
// Shouldnt happen. The article has a searchRowID, but we didnt find that row in the search table.
// Easy to recover from: just do an initial index, and alls 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);
"""
}
}

View File

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