mirror of
https://github.com/Ranchero-Software/NetNewsWire
synced 2025-08-12 06:26:36 +00:00
Move modules to Modules folder.
This commit is contained in:
@@ -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 don’t. 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, don’t 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 didn’t do this:
|
||||
/// 1) The database would grow to an inordinate size, and
|
||||
/// 2) the app would become very slow.
|
||||
public func cleanupDatabaseAtStartup(subscribedToFeedIDs: Set<String>) {
|
||||
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
@@ -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.
|
||||
// There’s a lookup table relating authorID and articleID.
|
||||
//
|
||||
// CREATE TABLE if not EXISTS authors (authorID TEXT NOT NULL PRIMARY KEY, name TEXT, url TEXT, avatarURL TEXT, emailAddress TEXT);
|
||||
// CREATE TABLE if not EXISTS authorLookup (authorID TEXT NOT NULL, articleID TEXT NOT NULL, PRIMARY KEY(authorID, articleID));
|
||||
|
||||
|
||||
final class AuthorsTable: DatabaseRelatedObjectsTable {
|
||||
|
||||
let name: 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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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 that’s good to have, and it’s likely that a feed removing dates is doing so in error.
|
||||
if datePublished != existingArticle.datePublished {
|
||||
if let updatedDatePublished = datePublished {
|
||||
d[DatabaseKey.datePublished] = updatedDatePublished
|
||||
}
|
||||
}
|
||||
if dateModified != existingArticle.dateModified {
|
||||
if let updatedDateModified = dateModified {
|
||||
d[DatabaseKey.dateModified] = updatedDateModified
|
||||
}
|
||||
}
|
||||
|
||||
return d.count < 1 ? nil : d
|
||||
}
|
||||
|
||||
// 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() }
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
//
|
||||
// ParsedArticle+Database.swift
|
||||
// NetNewsWire
|
||||
//
|
||||
// Created by Brent Simmons on 9/18/17.
|
||||
// Copyright © 2017 Ranchero Software. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Parser
|
||||
import Articles
|
||||
|
||||
extension ParsedItem {
|
||||
|
||||
var articleID: String {
|
||||
if let s = syncServiceID {
|
||||
return s
|
||||
}
|
||||
// Must be same calculation as for Article.
|
||||
return Article.calculatedArticleID(feedID: feedURL, uniqueID: uniqueID)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
//
|
||||
// RelatedObjectsMap+Database.swift
|
||||
// Database
|
||||
//
|
||||
// Created by Brent Simmons on 9/13/17.
|
||||
// Copyright © 2017 Ranchero Software. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import RSDatabase
|
||||
import Articles
|
||||
|
||||
extension RelatedObjectsMap {
|
||||
|
||||
func authors(for articleID: String) -> Set<Author>? {
|
||||
if let objects = self[articleID] {
|
||||
return objects.asAuthors()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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 didn’t. Recover by doing an initial index.
|
||||
performInitialIndexForArticles(articles, database)
|
||||
return
|
||||
}
|
||||
let groupedSearchInfos = Dictionary(grouping: searchInfos, by: { $0.rowID })
|
||||
let searchInfosDictionary = groupedSearchInfos.mapValues { $0.first! }
|
||||
|
||||
for article in articles {
|
||||
updateIndexForArticle(article, searchInfosDictionary, database)
|
||||
}
|
||||
}
|
||||
|
||||
private func updateIndexForArticle(_ article: ArticleSearchInfo, _ searchInfosDictionary: [Int: SearchInfo], _ database: FMDatabase) {
|
||||
guard let searchRowID = article.searchRowID else {
|
||||
assertionFailure("Expected article.searchRowID, got nil")
|
||||
return
|
||||
}
|
||||
guard let searchInfo: SearchInfo = searchInfosDictionary[searchRowID] else {
|
||||
// Shouldn’t happen. The article has a searchRowID, but we didn’t find that row in the search table.
|
||||
// Easy to recover from: just do an initial index, and all’s well.
|
||||
performInitialIndex(article, database)
|
||||
return
|
||||
}
|
||||
|
||||
let title = article.title ?? ""
|
||||
if title == searchInfo.title && article.bodyForIndex == searchInfo.body {
|
||||
return
|
||||
}
|
||||
|
||||
var updateDictionary = DatabaseDictionary()
|
||||
if title != searchInfo.title {
|
||||
updateDictionary[DatabaseKey.title] = title
|
||||
}
|
||||
if article.bodyForIndex != searchInfo.body {
|
||||
updateDictionary[DatabaseKey.body] = article.bodyForIndex
|
||||
}
|
||||
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) }
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user