Move modules to Modules folder.

This commit is contained in:
Brent Simmons
2025-01-06 21:13:56 -08:00
parent 430871c94a
commit 2933d9aca0
463 changed files with 2 additions and 20 deletions

5
Modules/ArticlesDatabase/.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
.DS_Store
/.build
/Packages
/*.xcodeproj
xcuserdata/

View File

@@ -0,0 +1,32 @@
// swift-tools-version:5.10
import PackageDescription
let package = Package(
name: "ArticlesDatabase",
platforms: [.macOS(.v14), .iOS(.v17)],
products: [
.library(
name: "ArticlesDatabase",
type: .dynamic,
targets: ["ArticlesDatabase"]),
],
dependencies: [
.package(path: "../RSDatabase"),
.package(path: "../Parser"),
.package(path: "../RSCore"),
.package(path: "../Articles"),
],
targets: [
.target(
name: "ArticlesDatabase",
dependencies: [
"RSCore",
"RSDatabase",
"Parser",
"Articles",
],
swiftSettings: [.unsafeFlags(["-warnings-as-errors"])]
),
]
)

View File

@@ -0,0 +1,3 @@
# ArticlesDatabase
A description of this package.

View File

@@ -0,0 +1,336 @@
//
// ArticlesDatabase.swift
// NetNewsWire
//
// Created by Brent Simmons on 7/20/15.
// Copyright © 2015 Ranchero Software, LLC. All rights reserved.
//
import Foundation
import RSCore
import RSDatabase
import Parser
import Articles
// This file is the entirety of the public API for ArticlesDatabase.framework.
// Everything else is implementation.
// Main thread only.
public typealias UnreadCountDictionary = [String: Int] // feedID: unreadCount
public typealias UnreadCountDictionaryCompletionResult = Result<UnreadCountDictionary,DatabaseError>
public typealias UnreadCountDictionaryCompletionBlock = (UnreadCountDictionaryCompletionResult) -> Void
public typealias SingleUnreadCountResult = Result<Int, DatabaseError>
public typealias SingleUnreadCountCompletionBlock = (SingleUnreadCountResult) -> Void
public struct ArticleChanges {
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
}
}
public typealias UpdateArticlesResult = Result<ArticleChanges, DatabaseError>
public typealias UpdateArticlesCompletionBlock = (UpdateArticlesResult) -> Void
public typealias ArticleSetResult = Result<Set<Article>, DatabaseError>
public typealias ArticleSetResultBlock = (ArticleSetResult) -> Void
public typealias ArticleIDsResult = Result<Set<String>, DatabaseError>
public typealias ArticleIDsCompletionBlock = (ArticleIDsResult) -> Void
public typealias ArticleStatusesResult = Result<Set<ArticleStatus>, DatabaseError>
public typealias ArticleStatusesResultBlock = (ArticleStatusesResult) -> Void
public final class ArticlesDatabase {
public enum RetentionStyle {
case feedBased // Local and iCloud: article retention is defined by contents of feed
case syncSystem // Feedbin, Feedly, etc.: article retention is defined by external system
}
private let articlesTable: ArticlesTable
private let queue: DatabaseQueue
private let operationQueue = MainThreadOperationQueue()
private let retentionStyle: RetentionStyle
public init(databaseFilePath: String, accountID: String, retentionStyle: RetentionStyle) {
let queue = DatabaseQueue(databasePath: databaseFilePath)
self.queue = queue
self.articlesTable = ArticlesTable(name: DatabaseTableName.articles, accountID: accountID, queue: queue, retentionStyle: retentionStyle)
self.retentionStyle = retentionStyle
try! queue.runCreateStatements(ArticlesDatabase.tableCreationStatements)
queue.runInDatabase { databaseResult in
let database = databaseResult.database!
if !self.articlesTable.containsColumn("searchRowID", in: database) {
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;")
}
DispatchQueue.main.async {
self.articlesTable.indexUnindexedArticles()
}
}
// MARK: - Fetching Articles
public func fetchArticles(_ feedID: String) throws -> Set<Article> {
return try articlesTable.fetchArticles(feedID)
}
public func fetchArticles(_ feedIDs: Set<String>) throws -> Set<Article> {
return try articlesTable.fetchArticles(feedIDs)
}
public func fetchArticles(articleIDs: Set<String>) throws -> Set<Article> {
return try articlesTable.fetchArticles(articleIDs: articleIDs)
}
public func fetchUnreadArticles(_ feedIDs: Set<String>, _ limit: Int?) throws -> Set<Article> {
return try articlesTable.fetchUnreadArticles(feedIDs, limit)
}
public func fetchTodayArticles(_ feedIDs: Set<String>, _ limit: Int?) throws -> Set<Article> {
return try articlesTable.fetchArticlesSince(feedIDs, todayCutoffDate(), limit)
}
public func fetchStarredArticles(_ feedIDs: Set<String>, _ limit: Int?) throws -> Set<Article> {
return try articlesTable.fetchStarredArticles(feedIDs, limit)
}
public func fetchStarredArticlesCount(_ feedIDs: Set<String>) throws -> Int {
return try articlesTable.fetchStarredArticlesCount(feedIDs)
}
public func fetchArticlesMatching(_ searchString: String, _ feedIDs: Set<String>) throws -> Set<Article> {
return try articlesTable.fetchArticlesMatching(searchString, feedIDs)
}
public func fetchArticlesMatchingWithArticleIDs(_ searchString: String, _ articleIDs: Set<String>) throws -> Set<Article> {
return try articlesTable.fetchArticlesMatchingWithArticleIDs(searchString, articleIDs)
}
// MARK: - Fetching Articles Async
public func fetchArticlesAsync(_ feedID: String, _ completion: @escaping ArticleSetResultBlock) {
articlesTable.fetchArticlesAsync(feedID, completion)
}
public func fetchArticlesAsync(_ feedIDs: Set<String>, _ completion: @escaping ArticleSetResultBlock) {
articlesTable.fetchArticlesAsync(feedIDs, completion)
}
public func fetchArticlesAsync(articleIDs: Set<String>, _ completion: @escaping ArticleSetResultBlock) {
articlesTable.fetchArticlesAsync(articleIDs: articleIDs, completion)
}
public func fetchUnreadArticlesAsync(_ feedIDs: Set<String>, _ limit: Int?, _ completion: @escaping ArticleSetResultBlock) {
articlesTable.fetchUnreadArticlesAsync(feedIDs, limit, completion)
}
public func fetchTodayArticlesAsync(_ feedIDs: Set<String>, _ limit: Int?, _ completion: @escaping ArticleSetResultBlock) {
articlesTable.fetchArticlesSinceAsync(feedIDs, todayCutoffDate(), limit, completion)
}
public func fetchedStarredArticlesAsync(_ feedIDs: Set<String>, _ limit: Int?, _ completion: @escaping ArticleSetResultBlock) {
articlesTable.fetchStarredArticlesAsync(feedIDs, limit, completion)
}
public func fetchArticlesMatchingAsync(_ searchString: String, _ feedIDs: Set<String>, _ completion: @escaping ArticleSetResultBlock) {
articlesTable.fetchArticlesMatchingAsync(searchString, feedIDs, completion)
}
public func fetchArticlesMatchingWithArticleIDsAsync(_ searchString: String, _ articleIDs: Set<String>, _ completion: @escaping ArticleSetResultBlock) {
articlesTable.fetchArticlesMatchingWithArticleIDsAsync(searchString, articleIDs, completion)
}
// MARK: - Unread Counts
/// Fetch all non-zero unread counts.
public func fetchAllUnreadCounts(_ completion: @escaping UnreadCountDictionaryCompletionBlock) {
let operation = FetchAllUnreadCountsOperation(databaseQueue: queue)
operationQueue.cancelOperations(named: operation.name!)
operation.completionBlock = { operation in
let fetchOperation = operation as! FetchAllUnreadCountsOperation
completion(fetchOperation.result)
}
operationQueue.add(operation)
}
/// Fetch unread count for a single feed.
public func fetchUnreadCount(_ feedID: String, _ completion: @escaping SingleUnreadCountCompletionBlock) {
let operation = FetchFeedUnreadCountOperation(feedID: feedID, databaseQueue: queue, cutoffDate: articlesTable.articleCutoffDate)
operation.completionBlock = { operation in
let fetchOperation = operation as! FetchFeedUnreadCountOperation
completion(fetchOperation.result)
}
operationQueue.add(operation)
}
/// Fetch non-zero unread counts for given feedIDs.
public func fetchUnreadCounts(for feedIDs: Set<String>, _ completion: @escaping UnreadCountDictionaryCompletionBlock) {
let operation = FetchUnreadCountsForFeedsOperation(feedIDs: feedIDs, databaseQueue: queue)
operation.completionBlock = { operation in
let fetchOperation = operation as! FetchUnreadCountsForFeedsOperation
completion(fetchOperation.result)
}
operationQueue.add(operation)
}
public func fetchUnreadCountForToday(for feedIDs: Set<String>, completion: @escaping SingleUnreadCountCompletionBlock) {
fetchUnreadCount(for: feedIDs, since: todayCutoffDate(), completion: completion)
}
public func fetchUnreadCount(for feedIDs: Set<String>, since: Date, completion: @escaping SingleUnreadCountCompletionBlock) {
articlesTable.fetchUnreadCount(feedIDs, since, completion)
}
public func fetchStarredAndUnreadCount(for feedIDs: Set<String>, completion: @escaping SingleUnreadCountCompletionBlock) {
articlesTable.fetchStarredAndUnreadCount(feedIDs, completion)
}
// MARK: - Saving, Updating, and Deleting Articles
/// Update articles and save new ones  for feed-based systems (local and iCloud).
public func update(with parsedItems: Set<ParsedItem>, feedID: String, deleteOlder: Bool, completion: @escaping UpdateArticlesCompletionBlock) {
precondition(retentionStyle == .feedBased)
articlesTable.update(parsedItems, feedID, deleteOlder, completion)
}
/// Update articles and save new ones for sync systems (Feedbin, Feedly, etc.).
public func update(feedIDsAndItems: [String: Set<ParsedItem>], defaultRead: Bool, completion: @escaping UpdateArticlesCompletionBlock) {
precondition(retentionStyle == .syncSystem)
articlesTable.update(feedIDsAndItems, defaultRead, completion)
}
/// Delete articles
public func delete(articleIDs: Set<String>, completion: DatabaseCompletionBlock?) {
articlesTable.delete(articleIDs: articleIDs, completion: completion)
}
// MARK: - Status
/// Fetch the articleIDs of unread articles.
public func fetchUnreadArticleIDsAsync(completion: @escaping ArticleIDsCompletionBlock) {
articlesTable.fetchUnreadArticleIDsAsync(completion)
}
/// Fetch the articleIDs of starred articles.
public func fetchStarredArticleIDsAsync(completion: @escaping ArticleIDsCompletionBlock) {
articlesTable.fetchStarredArticleIDsAsync(completion)
}
/// Fetch articleIDs for articles that we should have, but dont. These articles are either (starred) or (newer than the article cutoff date).
public func fetchArticleIDsForStatusesWithoutArticlesNewerThanCutoffDate(_ completion: @escaping ArticleIDsCompletionBlock) {
articlesTable.fetchArticleIDsForStatusesWithoutArticlesNewerThanCutoffDate(completion)
}
public func mark(_ articles: Set<Article>, statusKey: ArticleStatus.Key, flag: Bool, completion: @escaping ArticleStatusesResultBlock) {
return articlesTable.mark(articles, statusKey, flag, completion)
}
public func markAndFetchNew(articleIDs: Set<String>, statusKey: ArticleStatus.Key, flag: Bool, completion: @escaping ArticleIDsCompletionBlock) {
articlesTable.markAndFetchNew(articleIDs, statusKey, flag, completion)
}
/// 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>, completion: @escaping DatabaseCompletionBlock) {
articlesTable.createStatusesIfNeeded(articleIDs, completion)
}
#if os(iOS)
// MARK: - Suspend and Resume (for iOS)
/// Cancel current operations and close the database.
public func cancelAndSuspend() {
cancelOperations()
suspend()
}
/// Close the database and stop running database calls.
/// Any pending calls will complete first.
public func suspend() {
operationQueue.suspend()
queue.suspend()
}
/// Open the database and allow for running database calls again.
public func resume() {
queue.resume()
operationQueue.resume()
}
#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>) {
if retentionStyle == .syncSystem {
articlesTable.deleteOldArticles()
}
articlesTable.deleteArticlesNotInSubscribedToFeedIDs(subscribedToFeedIDs)
articlesTable.deleteOldStatuses()
}
}
// MARK: - Private
private extension ArticlesDatabase {
static let tableCreationStatements = """
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.
}
// MARK: - Operations
func cancelOperations() {
operationQueue.cancelAllOperations()
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,40 @@
//
// AuthorsTable.swift
// NetNewsWire
//
// Created by Brent Simmons on 7/13/17.
// Copyright © 2017 Ranchero Software. All rights reserved.
//
import Foundation
import RSDatabase
import RSDatabaseObjC
import Articles
// 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: String
let databaseIDKey = DatabaseKey.authorID
var cache = DatabaseObjectCache()
init(name: String) {
self.name = name
}
// MARK: - DatabaseRelatedObjectsTable
func objectWithRow(_ row: FMResultSet) -> DatabaseObject? {
if let author = Author(row: row) {
return author as DatabaseObject
}
return nil
}
}

View File

@@ -0,0 +1,64 @@
//
// 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"
}
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,220 @@
//
// Article+Database.swift
// NetNewsWire
//
// Created by Brent Simmons on 7/3/17.
// Copyright © 2017 Ranchero Software. All rights reserved.
//
import Foundation
import RSDatabase
import RSDatabaseObjC
import Articles
import Parser
extension Article {
convenience 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)
}
convenience 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
}
// static func articlesWithParsedItems(_ parsedItems: Set<ParsedItem>, _ accountID: String, _ feedID: String, _ statusesDictionary: [String: ArticleStatus]) -> Set<Article> {
// let maximumDateAllowed = Date().addingTimeInterval(60 * 60 * 24) // Allow dates up to about 24 hours ahead of now
// return Set(parsedItems.map{ Article(parsedItem: $0, maximumDateAllowed: maximumDateAllowed, accountID: accountID, feedID: feedID, status: statusesDictionary[$0.articleID]!) })
// }
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: @retroactive 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 RSDatabase
import RSDatabaseObjC
import Articles
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: @retroactive 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 RSDatabase
import RSDatabaseObjC
import Parser
// 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: @retroactive 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 RSDatabase
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 RSDatabase
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,75 @@
//
// FetchAllUnreadCountsOperation.swift
// ArticlesDatabase
//
// Created by Brent Simmons on 1/26/20.
// Copyright © 2020 Ranchero Software. All rights reserved.
//
import Foundation
import RSCore
import RSDatabase
import RSDatabaseObjC
public final class FetchAllUnreadCountsOperation: MainThreadOperation {
var result: UnreadCountDictionaryCompletionResult = .failure(.isSuspended)
// MainThreadOperation
public var isCanceled = false
public var id: Int?
public weak var operationDelegate: MainThreadOperationDelegate?
public var name: String? = "FetchAllUnreadCountsOperation"
public var completionBlock: MainThreadOperation.MainThreadOperationCompletionBlock?
private let queue: DatabaseQueue
init(databaseQueue: DatabaseQueue) {
self.queue = databaseQueue
}
public func run() {
queue.runInDatabase { databaseResult in
if self.isCanceled {
self.informOperationDelegateOfCompletion()
return
}
switch databaseResult {
case .success(let database):
self.fetchUnreadCounts(database)
case .failure:
self.informOperationDelegateOfCompletion()
}
}
}
}
private extension FetchAllUnreadCountsOperation {
func fetchUnreadCounts(_ database: FMDatabase) {
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 {
informOperationDelegateOfCompletion()
return
}
var unreadCountDictionary = UnreadCountDictionary()
while resultSet.next() {
if isCanceled {
resultSet.close()
informOperationDelegateOfCompletion()
return
}
let unreadCount = resultSet.long(forColumnIndex: 1)
if let feedID = resultSet.string(forColumnIndex: 0) {
unreadCountDictionary[feedID] = unreadCount
}
}
resultSet.close()
result = .success(unreadCountDictionary)
informOperationDelegateOfCompletion()
}
}

View File

@@ -0,0 +1,75 @@
//
// FetchFeedUnreadCountOperation.swift
// ArticlesDatabase
//
// Created by Brent Simmons on 1/27/20.
// Copyright © 2020 Ranchero Software. All rights reserved.
//
import Foundation
import RSCore
import RSDatabase
import RSDatabaseObjC
/// Fetch the unread count for a single feed.
public final class FetchFeedUnreadCountOperation: MainThreadOperation {
var result: SingleUnreadCountResult = .failure(.isSuspended)
// MainThreadOperation
public var isCanceled = false
public var id: Int?
public weak var operationDelegate: MainThreadOperationDelegate?
public var name: String? = "FetchFeedUnreadCountOperation"
public var completionBlock: MainThreadOperation.MainThreadOperationCompletionBlock?
private let queue: DatabaseQueue
private let cutoffDate: Date
private let feedID: String
init(feedID: String, databaseQueue: DatabaseQueue, cutoffDate: Date) {
self.feedID = feedID
self.queue = databaseQueue
self.cutoffDate = cutoffDate
}
public func run() {
queue.runInDatabase { databaseResult in
if self.isCanceled {
self.informOperationDelegateOfCompletion()
return
}
switch databaseResult {
case .success(let database):
self.fetchUnreadCount(database)
case .failure:
self.informOperationDelegateOfCompletion()
}
}
}
}
private extension FetchFeedUnreadCountOperation {
func fetchUnreadCount(_ database: FMDatabase) {
let sql = "select count(*) from articles natural join statuses where feedID=? and read=0;"
guard let resultSet = database.executeQuery(sql, withArgumentsIn: [feedID]) else {
informOperationDelegateOfCompletion()
return
}
if isCanceled {
informOperationDelegateOfCompletion()
return
}
if resultSet.next() {
let unreadCount = resultSet.long(forColumnIndex: 0)
result = .success(unreadCount)
}
resultSet.close()
informOperationDelegateOfCompletion()
}
}

View File

@@ -0,0 +1,86 @@
//
// FetchUnreadCountsForFeedsOperation.swift
// ArticlesDatabase
//
// Created by Brent Simmons on 2/1/20.
// Copyright © 2020 Ranchero Software. All rights reserved.
//
import Foundation
import RSCore
import RSDatabase
import RSDatabaseObjC
/// Fetch the unread counts for a number of feeds.
public final class FetchUnreadCountsForFeedsOperation: MainThreadOperation {
var result: UnreadCountDictionaryCompletionResult = .failure(.isSuspended)
// MainThreadOperation
public var isCanceled = false
public var id: Int?
public weak var operationDelegate: MainThreadOperationDelegate?
public var name: String? = "FetchUnreadCountsForFeedsOperation"
public var completionBlock: MainThreadOperation.MainThreadOperationCompletionBlock?
private let queue: DatabaseQueue
private let feedIDs: Set<String>
init(feedIDs: Set<String>, databaseQueue: DatabaseQueue) {
self.feedIDs = feedIDs
self.queue = databaseQueue
}
public func run() {
queue.runInDatabase { databaseResult in
if self.isCanceled {
self.informOperationDelegateOfCompletion()
return
}
switch databaseResult {
case .success(let database):
self.fetchUnreadCounts(database)
case .failure:
self.informOperationDelegateOfCompletion()
}
}
}
}
private extension FetchUnreadCountsForFeedsOperation {
func fetchUnreadCounts(_ database: FMDatabase) {
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 {
informOperationDelegateOfCompletion()
return
}
if isCanceled {
resultSet.close()
informOperationDelegateOfCompletion()
return
}
var unreadCountDictionary = UnreadCountDictionary()
while resultSet.next() {
if isCanceled {
resultSet.close()
informOperationDelegateOfCompletion()
return
}
let unreadCount = resultSet.long(forColumnIndex: 1)
if let feedID = resultSet.string(forColumnIndex: 0) {
unreadCountDictionary[feedID] = unreadCount
}
}
resultSet.close()
result = .success(unreadCountDictionary)
informOperationDelegateOfCompletion()
}
}

View File

@@ -0,0 +1,226 @@
//
// SearchTable.swift
// NetNewsWire
//
// Created by Brent Simmons on 2/23/19.
// Copyright © 2019 Ranchero Software. All rights reserved.
//
import Foundation
import RSCore
import RSDatabase
import RSDatabaseObjC
import Articles
import Parser
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 = HTMLEntityDecoder.decodedString(preferredText)
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: DatabaseTable {
let name = "search"
private let queue: DatabaseQueue
private weak var articlesTable: ArticlesTable?
init(queue: DatabaseQueue, articlesTable: ArticlesTable) {
self.queue = queue
self.articlesTable = articlesTable
}
func ensureIndexedArticles(for articleIDs: Set<String>) {
guard !articleIDs.isEmpty else {
return
}
queue.runInTransaction { databaseResult in
if let database = databaseResult.database {
self.ensureIndexedArticles(articleIDs, database)
}
}
}
/// Add to, or update, the search index for articles with specified IDs.
func ensureIndexedArticles(_ articleIDs: Set<String>, _ database: FMDatabase) {
guard let articlesTable = articlesTable else {
return
}
guard let articleSearchInfos = articlesTable.fetchArticleSearchInfos(articleIDs, in: 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(articles.articleIDs(), 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)
articlesTable?.updateRowsWithValue(rowid, valueKey: DatabaseKey.searchRowID, whereKey: DatabaseKey.articleID, matches: [article.articleID], database: database)
}
func insert(_ article: ArticleSearchInfo, _ database: FMDatabase) -> Int {
let rowDictionary: DatabaseDictionary = [DatabaseKey.body: article.bodyForIndex, DatabaseKey.title: article.title ?? ""]
insertRow(rowDictionary, insertType: .normal, in: database)
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
}
updateRowsWithDictionary(updateDictionary, whereKey: DatabaseKey.rowID, matches: searchInfo.rowID, database: database)
}
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) }
}
}

View File

@@ -0,0 +1,320 @@
//
// StatusesTable.swift
// NetNewsWire
//
// Created by Brent Simmons on 5/8/16.
// Copyright © 2016 Ranchero Software, LLC. All rights reserved.
//
import Foundation
import RSCore
import RSDatabase
import RSDatabaseObjC
import Articles
// 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: DatabaseTable {
let name = DatabaseTableName.statuses
private let cache = StatusCache()
private let queue: DatabaseQueue
init(queue: DatabaseQueue) {
self.queue = queue
}
// MARK: - Creating/Updating
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 = self.selectRowsWhere(key: DatabaseKey.articleID, inValues: Array(articleIDs), in: database) {
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 markAndFetchNew(_ articleIDs: Set<String>, _ statusKey: ArticleStatus.Key, _ flag: Bool, _ database: FMDatabase) -> Set<String> {
let (statusesDictionary, newStatusIDs) = ensureStatusesForArticleIDs(articleIDs, flag, database)
let statuses = Set(statusesDictionary.values)
mark(statuses, statusKey, flag, database)
return newStatusIDs
}
// MARK: - Fetching
func fetchUnreadArticleIDs() throws -> Set<String> {
return try fetchArticleIDs("select articleID from statuses where read=0;")
}
func fetchStarredArticleIDs() throws -> Set<String> {
return try fetchArticleIDs("select articleID from statuses where starred=1;")
}
func fetchArticleIDsAsync(_ statusKey: ArticleStatus.Key, _ value: Bool, _ completion: @escaping ArticleIDsCompletionBlock) {
queue.runInDatabase { databaseResult in
func makeDatabaseCalls(_ database: FMDatabase) {
var sql = "select articleID from statuses where \(statusKey.rawValue)="
sql += value ? "1" : "0"
sql += ";"
guard let resultSet = database.executeQuery(sql, withArgumentsIn: nil) else {
DispatchQueue.main.async {
completion(.success(Set<String>()))
}
return
}
let articleIDs = resultSet.mapToSet{ $0.string(forColumnIndex: 0) }
DispatchQueue.main.async {
completion(.success(articleIDs))
}
}
switch databaseResult {
case .success(let database):
makeDatabaseCalls(database)
case .failure(let databaseError):
DispatchQueue.main.async {
completion(.failure(databaseError))
}
}
}
}
func fetchArticleIDsForStatusesWithoutArticlesNewerThan(_ cutoffDate: Date, _ completion: @escaping ArticleIDsCompletionBlock) {
queue.runInDatabase { databaseResult in
var error: DatabaseError?
var articleIDs = Set<String>()
func makeDatabaseCall(_ database: FMDatabase) {
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);"
if let resultSet = database.executeQuery(sql, withArgumentsIn: [cutoffDate]) {
articleIDs = resultSet.mapToSet(self.articleIDWithRow)
}
}
switch databaseResult {
case .success(let database):
makeDatabaseCall(database)
case .failure(let databaseError):
error = databaseError
}
if let error = error {
DispatchQueue.main.async {
completion(.failure(error))
}
}
else {
DispatchQueue.main.async {
completion(.success(articleIDs))
}
}
}
}
func fetchArticleIDs(_ sql: String) throws -> Set<String> {
var error: DatabaseError?
var articleIDs = Set<String>()
queue.runInDatabaseSync { databaseResult in
switch databaseResult {
case .success(let database):
if let resultSet = database.executeQuery(sql, withArgumentsIn: nil) {
articleIDs = resultSet.mapToSet(self.articleIDWithRow)
}
case .failure(let databaseError):
error = databaseError
}
}
if let error = error {
throw(error)
}
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) {
deleteRowsWhere(key: DatabaseKey.articleID, equalsAnyValue: Array(articleIDs), in: database)
}
}
// 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()! }
self.insertRows(statusArray, insertType: .orIgnore, in: database)
}
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 = self.selectRowsWhere(key: DatabaseKey.articleID, inValues: Array(articleIDs), in: database) 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) {
updateRowsWithValue(NSNumber(value: flag), valueKey: statusKey.rawValue, whereKey: DatabaseKey.articleID, matches: Array(articleIDs), database: database)
}
}
// 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
}
}
}