2017-07-03 19:29:44 +02:00
|
|
|
|
//
|
|
|
|
|
// Account.swift
|
2019-07-09 08:06:40 +02:00
|
|
|
|
// NetNewsWire
|
2017-07-03 19:29:44 +02:00
|
|
|
|
//
|
|
|
|
|
// Created by Brent Simmons on 7/1/17.
|
|
|
|
|
// Copyright © 2017 Ranchero Software, LLC. All rights reserved.
|
|
|
|
|
//
|
|
|
|
|
|
2019-04-17 20:01:26 +02:00
|
|
|
|
#if os(iOS)
|
|
|
|
|
import UIKit
|
|
|
|
|
#endif
|
|
|
|
|
|
2017-07-03 19:29:44 +02:00
|
|
|
|
import Foundation
|
|
|
|
|
import RSCore
|
2018-07-24 03:29:08 +02:00
|
|
|
|
import Articles
|
2017-09-17 21:08:50 +02:00
|
|
|
|
import RSParser
|
2019-12-17 00:32:08 +01:00
|
|
|
|
import RSDatabase
|
2018-07-24 03:29:08 +02:00
|
|
|
|
import ArticlesDatabase
|
2017-10-08 02:20:19 +02:00
|
|
|
|
import RSWeb
|
2019-06-27 21:21:07 +02:00
|
|
|
|
import os.log
|
2017-07-03 19:29:44 +02:00
|
|
|
|
|
2019-07-07 23:01:44 +02:00
|
|
|
|
// Main thread only.
|
|
|
|
|
|
2017-10-07 23:40:14 +02:00
|
|
|
|
public extension Notification.Name {
|
2019-09-08 16:43:51 +02:00
|
|
|
|
static let UserDidAddAccount = Notification.Name("UserDidAddAccount")
|
|
|
|
|
static let UserDidDeleteAccount = Notification.Name("UserDidDeleteAccount")
|
2019-02-12 16:04:18 +01:00
|
|
|
|
static let AccountRefreshDidBegin = Notification.Name(rawValue: "AccountRefreshDidBegin")
|
|
|
|
|
static let AccountRefreshDidFinish = Notification.Name(rawValue: "AccountRefreshDidFinish")
|
|
|
|
|
static let AccountRefreshProgressDidChange = Notification.Name(rawValue: "AccountRefreshProgressDidChange")
|
2019-12-11 02:17:54 +01:00
|
|
|
|
static let DownloadArticlesDidUpdateUnreadCounts = Notification.Name(rawValue: "DownloadArticlesDidUpdateUnreadCounts")
|
2019-02-12 16:04:18 +01:00
|
|
|
|
static let AccountDidDownloadArticles = Notification.Name(rawValue: "AccountDidDownloadArticles")
|
2019-05-02 12:41:44 +02:00
|
|
|
|
static let AccountStateDidChange = Notification.Name(rawValue: "AccountStateDidChange")
|
2019-02-12 16:04:18 +01:00
|
|
|
|
static let StatusesDidChange = Notification.Name(rawValue: "StatusesDidChange")
|
2017-10-07 23:40:14 +02:00
|
|
|
|
}
|
|
|
|
|
|
2020-02-09 22:08:11 +01:00
|
|
|
|
public enum AccountType: Int, Codable {
|
2017-07-03 19:29:44 +02:00
|
|
|
|
// Raw values should not change since they’re stored on disk.
|
|
|
|
|
case onMyMac = 1
|
|
|
|
|
case feedly = 16
|
|
|
|
|
case feedbin = 17
|
|
|
|
|
case feedWrangler = 18
|
|
|
|
|
case newsBlur = 19
|
2019-06-20 14:22:51 +02:00
|
|
|
|
case freshRSS = 20
|
2017-07-03 19:29:44 +02:00
|
|
|
|
// TODO: more
|
|
|
|
|
}
|
|
|
|
|
|
2019-07-06 05:06:31 +02:00
|
|
|
|
public enum FetchType {
|
|
|
|
|
case starred
|
|
|
|
|
case unread
|
|
|
|
|
case today
|
2019-11-22 17:21:30 +01:00
|
|
|
|
case folder(Folder, Bool)
|
2019-11-15 03:11:41 +01:00
|
|
|
|
case webFeed(WebFeed)
|
2019-07-06 05:06:31 +02:00
|
|
|
|
case articleIDs(Set<String>)
|
|
|
|
|
case search(String)
|
2019-08-31 22:53:47 +02:00
|
|
|
|
case searchWithArticleIDs(String, Set<String>)
|
2019-07-06 05:06:31 +02:00
|
|
|
|
}
|
|
|
|
|
|
2017-10-10 22:23:12 +02:00
|
|
|
|
public final class Account: DisplayNameProvider, UnreadCountProvider, Container, Hashable {
|
2017-07-03 19:29:44 +02:00
|
|
|
|
|
2017-11-05 06:51:14 +01:00
|
|
|
|
public struct UserInfoKey {
|
2019-09-08 16:43:51 +02:00
|
|
|
|
public static let account = "account" // UserDidAddAccount, UserDidDeleteAccount
|
2017-10-09 06:06:25 +02:00
|
|
|
|
public static let newArticles = "newArticles" // AccountDidDownloadArticles
|
|
|
|
|
public static let updatedArticles = "updatedArticles" // AccountDidDownloadArticles
|
2017-10-09 07:25:33 +02:00
|
|
|
|
public static let statuses = "statuses" // StatusesDidChange
|
|
|
|
|
public static let articles = "articles" // StatusesDidChange
|
2019-12-17 07:45:59 +01:00
|
|
|
|
public static let articleIDs = "articleIDs" // StatusesDidChange
|
2019-11-15 03:11:41 +01:00
|
|
|
|
public static let webFeeds = "webFeeds" // AccountDidDownloadArticles, StatusesDidChange
|
2017-10-08 10:54:37 +02:00
|
|
|
|
}
|
|
|
|
|
|
2019-05-19 23:52:21 +02:00
|
|
|
|
public static let defaultLocalAccountName: String = {
|
|
|
|
|
let defaultName: String
|
|
|
|
|
#if os(macOS)
|
|
|
|
|
defaultName = NSLocalizedString("On My Mac", comment: "Account name")
|
|
|
|
|
#else
|
|
|
|
|
if UIDevice.current.userInterfaceIdiom == .pad {
|
|
|
|
|
defaultName = NSLocalizedString("On My iPad", comment: "Account name")
|
|
|
|
|
} else {
|
|
|
|
|
defaultName = NSLocalizedString("On My iPhone", comment: "Account name")
|
|
|
|
|
}
|
|
|
|
|
#endif
|
|
|
|
|
|
|
|
|
|
return defaultName
|
|
|
|
|
}()
|
|
|
|
|
|
2019-06-27 21:21:07 +02:00
|
|
|
|
var log = OSLog(subsystem: Bundle.main.bundleIdentifier!, category: "account")
|
|
|
|
|
|
2019-05-31 15:05:26 +02:00
|
|
|
|
public var isDeleted = false
|
|
|
|
|
|
2019-11-25 01:29:00 +01:00
|
|
|
|
public var containerID: ContainerIdentifier? {
|
|
|
|
|
return ContainerIdentifier.account(accountID)
|
|
|
|
|
}
|
|
|
|
|
|
2019-05-30 03:47:52 +02:00
|
|
|
|
public var account: Account? {
|
|
|
|
|
return self
|
|
|
|
|
}
|
2017-09-17 21:08:50 +02:00
|
|
|
|
public let accountID: String
|
2017-07-03 19:29:44 +02:00
|
|
|
|
public let type: AccountType
|
2019-03-28 06:10:14 +01:00
|
|
|
|
public var nameForDisplay: String {
|
|
|
|
|
guard let name = name, !name.isEmpty else {
|
|
|
|
|
return defaultName
|
|
|
|
|
}
|
|
|
|
|
return name
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public var name: String? {
|
|
|
|
|
get {
|
2019-05-05 14:49:59 +02:00
|
|
|
|
return metadata.name
|
2019-03-28 06:10:14 +01:00
|
|
|
|
}
|
|
|
|
|
set {
|
2019-04-01 01:12:03 +02:00
|
|
|
|
let currentNameForDisplay = nameForDisplay
|
2019-05-05 14:49:59 +02:00
|
|
|
|
if newValue != metadata.name {
|
|
|
|
|
metadata.name = newValue
|
2019-04-01 01:12:03 +02:00
|
|
|
|
if currentNameForDisplay != nameForDisplay {
|
|
|
|
|
postDisplayNameDidChangeNotification()
|
|
|
|
|
}
|
2019-03-28 06:10:14 +01:00
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
public let defaultName: String
|
2019-05-02 12:41:44 +02:00
|
|
|
|
|
|
|
|
|
public var isActive: Bool {
|
|
|
|
|
get {
|
2019-05-05 14:49:59 +02:00
|
|
|
|
return metadata.isActive
|
2019-05-02 12:41:44 +02:00
|
|
|
|
}
|
|
|
|
|
set {
|
2019-05-05 14:49:59 +02:00
|
|
|
|
if newValue != metadata.isActive {
|
|
|
|
|
metadata.isActive = newValue
|
2019-09-08 16:58:27 +02:00
|
|
|
|
var userInfo = [AnyHashable: Any]()
|
|
|
|
|
userInfo[UserInfoKey.account] = self
|
|
|
|
|
NotificationCenter.default.post(name: .AccountStateDidChange, object: self, userInfo: userInfo)
|
2019-05-02 12:41:44 +02:00
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2019-03-28 06:10:14 +01:00
|
|
|
|
|
2019-11-15 03:11:41 +01:00
|
|
|
|
public var topLevelWebFeeds = Set<WebFeed>()
|
2018-09-17 02:54:42 +02:00
|
|
|
|
public var folders: Set<Folder>? = Set<Folder>()
|
2019-11-16 19:02:58 +01:00
|
|
|
|
|
|
|
|
|
public var sortedFolders: [Folder]? {
|
|
|
|
|
if let folders = folders {
|
2019-11-16 20:25:55 +01:00
|
|
|
|
return Array(folders).sorted(by: { $0.nameForDisplay < $1.nameForDisplay })
|
2019-11-16 19:02:58 +01:00
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
2019-11-15 03:11:41 +01:00
|
|
|
|
private var webFeedDictionaryNeedsUpdate = true
|
|
|
|
|
private var _idToWebFeedDictionary = [String: WebFeed]()
|
|
|
|
|
var idToWebFeedDictionary: [String: WebFeed] {
|
|
|
|
|
if webFeedDictionaryNeedsUpdate {
|
|
|
|
|
rebuildWebFeedDictionaries()
|
2018-09-17 02:54:42 +02:00
|
|
|
|
}
|
2019-11-15 03:11:41 +01:00
|
|
|
|
return _idToWebFeedDictionary
|
2018-09-17 02:54:42 +02:00
|
|
|
|
}
|
|
|
|
|
|
2019-05-03 01:17:52 +02:00
|
|
|
|
var username: String? {
|
|
|
|
|
get {
|
2019-05-05 14:49:59 +02:00
|
|
|
|
return metadata.username
|
2019-05-03 01:17:52 +02:00
|
|
|
|
}
|
|
|
|
|
set {
|
2019-05-05 14:49:59 +02:00
|
|
|
|
if newValue != metadata.username {
|
|
|
|
|
metadata.username = newValue
|
2019-05-03 01:17:52 +02:00
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2019-05-29 21:16:09 +02:00
|
|
|
|
public var endpointURL: URL? {
|
|
|
|
|
get {
|
|
|
|
|
return metadata.endpointURL
|
|
|
|
|
}
|
|
|
|
|
set {
|
|
|
|
|
if newValue != metadata.endpointURL {
|
|
|
|
|
metadata.endpointURL = newValue
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2018-09-17 02:54:42 +02:00
|
|
|
|
private var fetchingAllUnreadCounts = false
|
2019-04-27 23:16:46 +02:00
|
|
|
|
var isUnreadCountsInitialized = false
|
2018-09-17 02:54:42 +02:00
|
|
|
|
|
2017-07-03 19:29:44 +02:00
|
|
|
|
let dataFolder: String
|
2018-07-24 03:29:08 +02:00
|
|
|
|
let database: ArticlesDatabase
|
2019-05-05 10:25:21 +02:00
|
|
|
|
var delegate: AccountDelegate
|
2018-02-18 00:38:54 +01:00
|
|
|
|
static let saveQueue = CoalescingQueue(name: "Account Save Queue", interval: 1.0)
|
2017-10-08 03:15:42 +02:00
|
|
|
|
|
2018-09-15 07:06:03 +02:00
|
|
|
|
private var unreadCounts = [String: Int]() // [feedID: Int]
|
2018-09-14 22:25:38 +02:00
|
|
|
|
|
2019-11-15 03:11:41 +01:00
|
|
|
|
private var _flattenedWebFeeds = Set<WebFeed>()
|
|
|
|
|
private var flattenedWebFeedsNeedUpdate = true
|
2018-09-17 02:54:42 +02:00
|
|
|
|
|
2019-09-13 23:12:19 +02:00
|
|
|
|
private lazy var opmlFile = OPMLFile(filename: (dataFolder as NSString).appendingPathComponent("Subscriptions.opml"), account: self)
|
2019-09-13 23:41:08 +02:00
|
|
|
|
private lazy var metadataFile = AccountMetadataFile(filename: (dataFolder as NSString).appendingPathComponent("Settings.plist"), account: self)
|
2019-09-23 16:57:50 +02:00
|
|
|
|
var metadata = AccountMetadata() {
|
|
|
|
|
didSet {
|
|
|
|
|
delegate.accountMetadata = metadata
|
|
|
|
|
}
|
|
|
|
|
}
|
2019-03-21 06:10:22 +01:00
|
|
|
|
|
2019-11-15 03:11:41 +01:00
|
|
|
|
private lazy var webFeedMetadataFile = WebFeedMetadataFile(filename: (dataFolder as NSString).appendingPathComponent("FeedMetadata.plist"), account: self)
|
|
|
|
|
typealias WebFeedMetadataDictionary = [String: WebFeedMetadata]
|
|
|
|
|
var webFeedMetadata = WebFeedMetadataDictionary()
|
2019-03-14 07:41:43 +01:00
|
|
|
|
|
2017-10-10 22:23:12 +02:00
|
|
|
|
public var unreadCount = 0 {
|
|
|
|
|
didSet {
|
|
|
|
|
if unreadCount != oldValue {
|
|
|
|
|
postUnreadCountDidChangeNotification()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2019-09-20 18:34:31 +02:00
|
|
|
|
public var behaviors: AccountBehaviors {
|
|
|
|
|
return delegate.behaviors
|
2019-05-29 00:42:19 +02:00
|
|
|
|
}
|
|
|
|
|
|
2017-10-07 23:40:14 +02:00
|
|
|
|
var refreshInProgress = false {
|
|
|
|
|
didSet {
|
|
|
|
|
if refreshInProgress != oldValue {
|
|
|
|
|
if refreshInProgress {
|
|
|
|
|
NotificationCenter.default.post(name: .AccountRefreshDidBegin, object: self)
|
|
|
|
|
}
|
|
|
|
|
else {
|
2017-10-08 03:31:34 +02:00
|
|
|
|
NotificationCenter.default.post(name: .AccountRefreshDidFinish, object: self)
|
2019-09-23 17:35:48 +02:00
|
|
|
|
opmlFile.markAsDirty()
|
2017-10-07 23:40:14 +02:00
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2017-09-28 22:16:47 +02:00
|
|
|
|
|
2017-10-08 02:20:19 +02:00
|
|
|
|
var refreshProgress: DownloadProgress {
|
2018-02-14 22:14:25 +01:00
|
|
|
|
return delegate.refreshProgress
|
2017-10-08 02:20:19 +02:00
|
|
|
|
}
|
2020-01-28 08:00:48 +01:00
|
|
|
|
|
2019-05-12 14:22:33 +02:00
|
|
|
|
init?(dataFolder: String, type: AccountType, accountID: String, transport: Transport? = nil) {
|
2019-05-03 01:17:52 +02:00
|
|
|
|
switch type {
|
|
|
|
|
case .onMyMac:
|
|
|
|
|
self.delegate = LocalAccountDelegate()
|
|
|
|
|
case .feedbin:
|
2019-05-15 01:24:19 +02:00
|
|
|
|
self.delegate = FeedbinAccountDelegate(dataFolder: dataFolder, transport: transport)
|
2019-06-20 14:22:51 +02:00
|
|
|
|
case .freshRSS:
|
2019-06-19 18:25:37 +02:00
|
|
|
|
self.delegate = ReaderAPIAccountDelegate(dataFolder: dataFolder, transport: transport)
|
2019-09-18 01:18:06 +02:00
|
|
|
|
case .feedly:
|
2019-11-07 08:54:41 +01:00
|
|
|
|
self.delegate = FeedlyAccountDelegate(dataFolder: dataFolder, transport: transport, api: FeedlyAccountDelegate.environment)
|
2019-09-28 06:44:58 +02:00
|
|
|
|
case .feedWrangler:
|
|
|
|
|
self.delegate = FeedWranglerAccountDelegate(dataFolder: dataFolder, transport: transport)
|
2019-05-03 01:17:52 +02:00
|
|
|
|
default:
|
2019-09-20 18:41:28 +02:00
|
|
|
|
return nil
|
2019-05-03 01:17:52 +02:00
|
|
|
|
}
|
2017-09-18 02:03:58 +02:00
|
|
|
|
|
2019-12-16 22:19:55 +01:00
|
|
|
|
self.delegate.accountMetadata = metadata
|
|
|
|
|
|
2017-09-18 02:03:58 +02:00
|
|
|
|
self.accountID = accountID
|
|
|
|
|
self.type = type
|
|
|
|
|
self.dataFolder = dataFolder
|
2017-12-20 02:48:30 +01:00
|
|
|
|
|
2017-09-18 02:03:58 +02:00
|
|
|
|
let databaseFilePath = (dataFolder as NSString).appendingPathComponent("DB.sqlite3")
|
2018-07-24 03:29:08 +02:00
|
|
|
|
self.database = ArticlesDatabase(databaseFilePath: databaseFilePath, accountID: accountID)
|
2017-09-27 22:29:05 +02:00
|
|
|
|
|
2019-03-28 06:10:14 +01:00
|
|
|
|
switch type {
|
|
|
|
|
case .onMyMac:
|
2019-05-19 23:52:21 +02:00
|
|
|
|
defaultName = Account.defaultLocalAccountName
|
2019-03-28 06:10:14 +01:00
|
|
|
|
case .feedly:
|
|
|
|
|
defaultName = "Feedly"
|
|
|
|
|
case .feedbin:
|
|
|
|
|
defaultName = "Feedbin"
|
|
|
|
|
case .feedWrangler:
|
|
|
|
|
defaultName = "FeedWrangler"
|
|
|
|
|
case .newsBlur:
|
|
|
|
|
defaultName = "NewsBlur"
|
2019-06-20 14:22:51 +02:00
|
|
|
|
case .freshRSS:
|
|
|
|
|
defaultName = "FreshRSS"
|
2019-03-28 06:10:14 +01:00
|
|
|
|
}
|
|
|
|
|
|
2017-10-08 02:43:10 +02:00
|
|
|
|
NotificationCenter.default.addObserver(self, selector: #selector(downloadProgressDidChange(_:)), name: .DownloadProgressDidChange, object: nil)
|
2017-10-13 06:02:27 +02:00
|
|
|
|
NotificationCenter.default.addObserver(self, selector: #selector(unreadCountDidChange(_:)), name: .UnreadCountDidChange, object: nil)
|
2018-01-24 06:49:33 +01:00
|
|
|
|
NotificationCenter.default.addObserver(self, selector: #selector(batchUpdateDidPerform(_:)), name: .BatchUpdateDidPerform, object: nil)
|
|
|
|
|
NotificationCenter.default.addObserver(self, selector: #selector(displayNameDidChange(_:)), name: .DisplayNameDidChange, object: nil)
|
2018-02-25 00:54:32 +01:00
|
|
|
|
NotificationCenter.default.addObserver(self, selector: #selector(childrenDidChange(_:)), name: .ChildrenDidChange, object: nil)
|
2017-11-25 22:48:14 +01:00
|
|
|
|
|
2019-09-13 23:35:53 +02:00
|
|
|
|
metadataFile.load()
|
2019-11-15 03:11:41 +01:00
|
|
|
|
webFeedMetadataFile.load()
|
2019-09-13 23:35:53 +02:00
|
|
|
|
opmlFile.load()
|
|
|
|
|
|
2017-10-19 03:37:45 +02:00
|
|
|
|
DispatchQueue.main.async {
|
2019-11-15 03:11:41 +01:00
|
|
|
|
self.database.cleanupDatabaseAtStartup(subscribedToWebFeedIDs: self.flattenedWebFeeds().webFeedIDs())
|
2017-12-03 20:57:53 +01:00
|
|
|
|
self.fetchAllUnreadCounts()
|
2017-10-19 03:37:45 +02:00
|
|
|
|
}
|
2017-12-20 02:48:30 +01:00
|
|
|
|
|
|
|
|
|
self.delegate.accountDidInitialize(self)
|
2017-07-03 19:29:44 +02:00
|
|
|
|
}
|
|
|
|
|
|
2017-09-17 20:32:58 +02:00
|
|
|
|
// MARK: - API
|
2019-05-03 01:17:52 +02:00
|
|
|
|
|
2019-05-04 22:14:49 +02:00
|
|
|
|
public func storeCredentials(_ credentials: Credentials) throws {
|
2019-09-15 17:03:47 +02:00
|
|
|
|
username = credentials.username
|
2019-05-05 10:25:21 +02:00
|
|
|
|
guard let server = delegate.server else {
|
2019-09-15 17:03:47 +02:00
|
|
|
|
assertionFailure()
|
|
|
|
|
return
|
2019-05-04 22:14:49 +02:00
|
|
|
|
}
|
2019-05-05 10:25:21 +02:00
|
|
|
|
try CredentialsManager.storeCredentials(credentials, server: server)
|
2019-05-05 13:02:28 +02:00
|
|
|
|
delegate.credentials = credentials
|
2019-05-04 22:14:49 +02:00
|
|
|
|
}
|
|
|
|
|
|
2019-09-15 17:03:47 +02:00
|
|
|
|
public func retrieveCredentials(type: CredentialsType) throws -> Credentials? {
|
|
|
|
|
guard let username = self.username, let server = delegate.server else {
|
2019-05-30 13:48:34 +02:00
|
|
|
|
return nil
|
|
|
|
|
}
|
2019-09-15 17:03:47 +02:00
|
|
|
|
return try CredentialsManager.retrieveCredentials(type: type, server: server, username: username)
|
2019-05-30 13:48:34 +02:00
|
|
|
|
}
|
|
|
|
|
|
2019-09-15 17:03:47 +02:00
|
|
|
|
public func removeCredentials(type: CredentialsType) throws {
|
|
|
|
|
guard let username = self.username, let server = delegate.server else {
|
|
|
|
|
return
|
2019-05-30 13:48:34 +02:00
|
|
|
|
}
|
2019-09-15 17:03:47 +02:00
|
|
|
|
try CredentialsManager.removeCredentials(type: type, server: server, username: username)
|
2019-05-30 13:48:34 +02:00
|
|
|
|
}
|
|
|
|
|
|
2019-05-29 21:16:09 +02:00
|
|
|
|
public static func validateCredentials(transport: Transport = URLSession.webserviceTransport(), type: AccountType, credentials: Credentials, endpoint: URL? = nil, completion: @escaping (Result<Credentials?, Error>) -> Void) {
|
2019-05-03 01:17:52 +02:00
|
|
|
|
switch type {
|
|
|
|
|
case .feedbin:
|
2019-05-06 17:53:20 +02:00
|
|
|
|
FeedbinAccountDelegate.validateCredentials(transport: transport, credentials: credentials, completion: completion)
|
2019-06-20 14:22:51 +02:00
|
|
|
|
case .freshRSS:
|
2019-06-19 18:25:37 +02:00
|
|
|
|
ReaderAPIAccountDelegate.validateCredentials(transport: transport, credentials: credentials, endpoint: endpoint, completion: completion)
|
2019-09-28 06:44:58 +02:00
|
|
|
|
case .feedWrangler:
|
|
|
|
|
FeedWranglerAccountDelegate.validateCredentials(transport: transport, credentials: credentials, completion: completion)
|
2019-05-03 01:17:52 +02:00
|
|
|
|
default:
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
}
|
2019-09-18 01:18:06 +02:00
|
|
|
|
|
2019-11-08 08:35:22 +01:00
|
|
|
|
internal static func oauthAuthorizationClient(for type: AccountType) -> OAuthAuthorizationClient {
|
|
|
|
|
switch type {
|
|
|
|
|
case .feedly:
|
|
|
|
|
return FeedlyAccountDelegate.environment.oauthAuthorizationClient
|
|
|
|
|
default:
|
|
|
|
|
fatalError("\(type) is not a client for OAuth authorization code granting.")
|
2019-11-07 08:54:41 +01:00
|
|
|
|
}
|
|
|
|
|
}
|
2019-11-07 23:51:59 +01:00
|
|
|
|
|
2019-11-08 08:35:22 +01:00
|
|
|
|
public static func oauthAuthorizationCodeGrantRequest(for type: AccountType) -> URLRequest {
|
2019-09-18 01:18:06 +02:00
|
|
|
|
let grantingType: OAuthAuthorizationGranting.Type
|
|
|
|
|
switch type {
|
|
|
|
|
case .feedly:
|
|
|
|
|
grantingType = FeedlyAccountDelegate.self
|
|
|
|
|
default:
|
|
|
|
|
fatalError("\(type) does not support OAuth authorization code granting.")
|
|
|
|
|
}
|
|
|
|
|
|
2019-11-08 08:35:22 +01:00
|
|
|
|
return grantingType.oauthAuthorizationCodeGrantRequest()
|
2019-09-18 01:18:06 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public static func requestOAuthAccessToken(with response: OAuthAuthorizationResponse,
|
|
|
|
|
client: OAuthAuthorizationClient,
|
|
|
|
|
accountType: AccountType,
|
|
|
|
|
transport: Transport = URLSession.webserviceTransport(),
|
2019-12-15 01:14:55 +01:00
|
|
|
|
completion: @escaping (Result<OAuthAuthorizationGrant, Error>) -> ()) {
|
2019-09-18 01:18:06 +02:00
|
|
|
|
let grantingType: OAuthAuthorizationGranting.Type
|
|
|
|
|
|
|
|
|
|
switch accountType {
|
|
|
|
|
case .feedly:
|
|
|
|
|
grantingType = FeedlyAccountDelegate.self
|
|
|
|
|
default:
|
|
|
|
|
fatalError("\(accountType) does not support OAuth authorization code granting.")
|
|
|
|
|
}
|
|
|
|
|
|
2019-12-15 01:14:55 +01:00
|
|
|
|
grantingType.requestOAuthAccessToken(with: response, transport: transport, completion: completion)
|
2019-09-18 01:18:06 +02:00
|
|
|
|
}
|
2017-09-17 20:32:58 +02:00
|
|
|
|
|
2019-05-26 18:54:32 +02:00
|
|
|
|
public func refreshAll(completion: @escaping (Result<Void, Error>) -> Void) {
|
2019-05-15 18:52:56 +02:00
|
|
|
|
self.delegate.refreshAll(for: self, completion: completion)
|
2017-07-03 19:29:44 +02:00
|
|
|
|
}
|
2017-09-17 00:30:26 +02:00
|
|
|
|
|
2019-11-05 03:24:21 +01:00
|
|
|
|
public func syncArticleStatus(completion: ((Result<Void, Error>) -> Void)? = nil) {
|
|
|
|
|
delegate.sendArticleStatus(for: self) { [unowned self] result in
|
|
|
|
|
switch result {
|
|
|
|
|
case .success:
|
|
|
|
|
self.delegate.refreshArticleStatus(for: self) { result in
|
|
|
|
|
switch result {
|
|
|
|
|
case .success:
|
|
|
|
|
completion?(.success(()))
|
|
|
|
|
case .failure(let error):
|
|
|
|
|
completion?(.failure(error))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
case .failure(let error):
|
|
|
|
|
completion?(.failure(error))
|
2019-05-15 18:52:56 +02:00
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2019-05-11 19:26:23 +02:00
|
|
|
|
public func importOPML(_ opmlFile: URL, completion: @escaping (Result<Void, Error>) -> Void) {
|
2019-06-20 00:50:32 +02:00
|
|
|
|
guard !delegate.isOPMLImportInProgress else {
|
2019-05-17 17:44:22 +02:00
|
|
|
|
completion(.failure(AccountError.opmlImportInProgress))
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2019-05-13 02:31:29 +02:00
|
|
|
|
delegate.importOPML(for: self, opmlFile: opmlFile) { [weak self] result in
|
|
|
|
|
switch result {
|
|
|
|
|
case .success:
|
|
|
|
|
guard let self = self else { return }
|
2019-05-17 17:04:13 +02:00
|
|
|
|
// Reset the last fetch date to get the article history for the added feeds.
|
2019-12-10 02:34:26 +01:00
|
|
|
|
self.metadata.lastArticleFetchStartTime = nil
|
2019-05-26 18:54:32 +02:00
|
|
|
|
self.delegate.refreshAll(for: self, completion: completion)
|
2019-05-13 02:31:29 +02:00
|
|
|
|
case .failure(let error):
|
|
|
|
|
completion(.failure(error))
|
|
|
|
|
}
|
|
|
|
|
}
|
2019-05-17 17:44:22 +02:00
|
|
|
|
|
2017-09-17 21:08:50 +02:00
|
|
|
|
}
|
2019-05-11 19:26:23 +02:00
|
|
|
|
|
2019-12-05 01:27:39 +01:00
|
|
|
|
public func suspendNetwork() {
|
|
|
|
|
delegate.suspendNetwork()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public func suspendDatabase() {
|
2020-02-06 07:17:32 +01:00
|
|
|
|
database.cancelAndSuspend()
|
2019-11-05 03:24:21 +01:00
|
|
|
|
save()
|
|
|
|
|
}
|
2019-11-30 07:57:14 +01:00
|
|
|
|
|
2019-12-05 07:11:20 +01:00
|
|
|
|
/// Re-open the SQLite database and allow database calls.
|
|
|
|
|
/// Call this *before* calling resume.
|
|
|
|
|
public func resumeDatabaseAndDelegate() {
|
2019-11-30 07:57:14 +01:00
|
|
|
|
database.resume()
|
|
|
|
|
delegate.resume()
|
2019-12-05 07:11:20 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Reload OPML, etc.
|
|
|
|
|
public func resume() {
|
2020-01-30 08:09:38 +01:00
|
|
|
|
fetchAllUnreadCounts()
|
2019-11-30 07:57:14 +01:00
|
|
|
|
}
|
|
|
|
|
|
2019-10-02 22:32:34 +02:00
|
|
|
|
public func save() {
|
|
|
|
|
metadataFile.save()
|
2019-11-15 03:11:41 +01:00
|
|
|
|
webFeedMetadataFile.save()
|
2019-10-02 22:32:34 +02:00
|
|
|
|
opmlFile.save()
|
2019-09-23 18:09:40 +02:00
|
|
|
|
}
|
|
|
|
|
|
2019-11-11 08:42:31 +01:00
|
|
|
|
public func prepareForDeletion() {
|
|
|
|
|
delegate.accountWillBeDeleted(self)
|
|
|
|
|
}
|
2020-01-28 08:00:48 +01:00
|
|
|
|
|
2019-09-13 01:05:29 +02:00
|
|
|
|
func loadOPMLItems(_ items: [RSOPMLItem], parentFolder: Folder?) {
|
2019-11-15 03:11:41 +01:00
|
|
|
|
var feedsToAdd = Set<WebFeed>()
|
2019-09-13 01:05:29 +02:00
|
|
|
|
|
|
|
|
|
items.forEach { (item) in
|
|
|
|
|
|
|
|
|
|
if let feedSpecifier = item.feedSpecifier {
|
2019-11-15 03:11:41 +01:00
|
|
|
|
let feed = newWebFeed(with: feedSpecifier)
|
2019-09-13 01:05:29 +02:00
|
|
|
|
feedsToAdd.insert(feed)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
guard let folderName = item.titleFromAttributes else {
|
|
|
|
|
// Folder doesn’t have a name, so it won’t be created, and its items will go one level up.
|
|
|
|
|
if let itemChildren = item.children {
|
|
|
|
|
loadOPMLItems(itemChildren, parentFolder: parentFolder)
|
|
|
|
|
}
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if let folder = ensureFolder(with: folderName) {
|
2019-09-26 00:09:21 +02:00
|
|
|
|
folder.externalID = item.attributes?["nnw_externalID"] as? String
|
2019-09-13 01:05:29 +02:00
|
|
|
|
if let itemChildren = item.children {
|
|
|
|
|
loadOPMLItems(itemChildren, parentFolder: folder)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if let parentFolder = parentFolder {
|
|
|
|
|
for feed in feedsToAdd {
|
2019-11-15 03:11:41 +01:00
|
|
|
|
parentFolder.addWebFeed(feed)
|
2019-09-13 01:05:29 +02:00
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
for feed in feedsToAdd {
|
2019-11-15 03:11:41 +01:00
|
|
|
|
addWebFeed(feed)
|
2019-09-13 01:05:29 +02:00
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
2017-10-29 19:14:10 +01:00
|
|
|
|
public func markArticles(_ articles: Set<Article>, statusKey: ArticleStatus.Key, flag: Bool) -> Set<Article>? {
|
2019-05-14 22:34:05 +02:00
|
|
|
|
return delegate.markArticles(for: self, articles: articles, statusKey: statusKey, flag: flag)
|
2017-09-18 01:30:45 +02:00
|
|
|
|
}
|
2017-10-19 04:46:35 +02:00
|
|
|
|
|
|
|
|
|
@discardableResult
|
2019-09-27 13:38:43 +02:00
|
|
|
|
func ensureFolder(with name: String) -> Folder? {
|
2017-10-19 04:46:35 +02:00
|
|
|
|
// TODO: support subfolders, maybe, some day
|
|
|
|
|
|
|
|
|
|
if name.isEmpty {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if let folder = existingFolder(with: name) {
|
|
|
|
|
return folder
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let folder = Folder(account: self, name: name)
|
2018-09-17 02:54:42 +02:00
|
|
|
|
folders!.insert(folder)
|
2018-09-17 05:02:24 +02:00
|
|
|
|
structureDidChange()
|
2017-10-19 04:46:35 +02:00
|
|
|
|
|
2017-10-19 22:27:59 +02:00
|
|
|
|
postChildrenDidChangeNotification()
|
2017-10-19 04:46:35 +02:00
|
|
|
|
return folder
|
2017-09-17 22:07:55 +02:00
|
|
|
|
}
|
2017-09-25 22:31:36 +02:00
|
|
|
|
|
2017-11-05 03:03:47 +01:00
|
|
|
|
public func ensureFolder(withFolderNames folderNames: [String]) -> Folder? {
|
|
|
|
|
// TODO: support subfolders, maybe, some day.
|
|
|
|
|
// Since we don’t, just take the last name and make sure there’s a Folder.
|
|
|
|
|
|
|
|
|
|
guard let folderName = folderNames.last else {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
return ensureFolder(with: folderName)
|
|
|
|
|
}
|
|
|
|
|
|
2019-10-21 01:49:17 +02:00
|
|
|
|
public func findFolder(withDisplayName displayName: String) -> Folder? {
|
|
|
|
|
return folders?.first(where: { $0.nameForDisplay == displayName })
|
|
|
|
|
}
|
|
|
|
|
|
2019-11-15 03:11:41 +01:00
|
|
|
|
func newWebFeed(with opmlFeedSpecifier: RSOPMLFeedSpecifier) -> WebFeed {
|
2019-05-11 23:07:27 +02:00
|
|
|
|
let feedURL = opmlFeedSpecifier.feedURL
|
2019-11-15 03:11:41 +01:00
|
|
|
|
let metadata = webFeedMetadata(feedURL: feedURL, webFeedID: feedURL)
|
|
|
|
|
let feed = WebFeed(account: self, url: opmlFeedSpecifier.feedURL, metadata: metadata)
|
2019-05-11 23:07:27 +02:00
|
|
|
|
if let feedTitle = opmlFeedSpecifier.title {
|
|
|
|
|
if feed.name == nil {
|
|
|
|
|
feed.name = feedTitle
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return feed
|
|
|
|
|
}
|
|
|
|
|
|
2019-11-15 03:11:41 +01:00
|
|
|
|
public func addWebFeed(_ feed: WebFeed, to container: Container, completion: @escaping (Result<Void, Error>) -> Void) {
|
|
|
|
|
delegate.addWebFeed(for: self, with: feed, to: container, completion: completion)
|
2018-09-17 02:54:42 +02:00
|
|
|
|
}
|
|
|
|
|
|
2019-11-15 03:11:41 +01:00
|
|
|
|
public func createWebFeed(url: String, name: String?, container: Container, completion: @escaping (Result<WebFeed, Error>) -> Void) {
|
|
|
|
|
delegate.createWebFeed(for: self, url: url, name: name, container: container, completion: completion)
|
2019-05-07 17:51:41 +02:00
|
|
|
|
}
|
|
|
|
|
|
2019-11-15 03:11:41 +01:00
|
|
|
|
func createWebFeed(with name: String?, url: String, webFeedID: String, homePageURL: String?) -> WebFeed {
|
|
|
|
|
let metadata = webFeedMetadata(feedURL: url, webFeedID: webFeedID)
|
|
|
|
|
let feed = WebFeed(account: self, url: url, metadata: metadata)
|
2019-05-07 17:51:41 +02:00
|
|
|
|
feed.name = name
|
|
|
|
|
feed.homePageURL = homePageURL
|
|
|
|
|
|
2017-10-01 01:56:48 +02:00
|
|
|
|
return feed
|
|
|
|
|
}
|
|
|
|
|
|
2019-11-15 03:11:41 +01:00
|
|
|
|
public func removeWebFeed(_ feed: WebFeed, from container: Container, completion: @escaping (Result<Void, Error>) -> Void) {
|
|
|
|
|
delegate.removeWebFeed(for: self, with: feed, from: container, completion: completion)
|
2019-05-09 23:09:21 +02:00
|
|
|
|
}
|
|
|
|
|
|
2019-11-15 03:11:41 +01:00
|
|
|
|
public func moveWebFeed(_ feed: WebFeed, from: Container, to: Container, completion: @escaping (Result<Void, Error>) -> Void) {
|
|
|
|
|
delegate.moveWebFeed(for: self, with: feed, from: from, to: to, completion: completion)
|
2019-05-09 23:09:21 +02:00
|
|
|
|
}
|
|
|
|
|
|
2019-11-15 03:11:41 +01:00
|
|
|
|
public func renameWebFeed(_ feed: WebFeed, to name: String, completion: @escaping (Result<Void, Error>) -> Void) {
|
|
|
|
|
delegate.renameWebFeed(for: self, with: feed, to: name, completion: completion)
|
2019-05-09 00:41:19 +02:00
|
|
|
|
}
|
|
|
|
|
|
2019-11-15 03:11:41 +01:00
|
|
|
|
public func restoreWebFeed(_ feed: WebFeed, container: Container, completion: @escaping (Result<Void, Error>) -> Void) {
|
|
|
|
|
delegate.restoreWebFeed(for: self, feed: feed, container: container, completion: completion)
|
2019-05-09 23:09:21 +02:00
|
|
|
|
}
|
|
|
|
|
|
2019-05-30 21:36:21 +02:00
|
|
|
|
public func addFolder(_ name: String, completion: @escaping (Result<Folder, Error>) -> Void) {
|
|
|
|
|
delegate.addFolder(for: self, name: name, completion: completion)
|
2019-05-09 23:09:21 +02:00
|
|
|
|
}
|
|
|
|
|
|
2019-05-30 03:53:00 +02:00
|
|
|
|
public func removeFolder(_ folder: Folder, completion: @escaping (Result<Void, Error>) -> Void) {
|
|
|
|
|
delegate.removeFolder(for: self, with: folder, completion: completion)
|
2017-09-25 22:31:36 +02:00
|
|
|
|
}
|
2019-05-06 17:53:20 +02:00
|
|
|
|
|
|
|
|
|
public func renameFolder(_ folder: Folder, to name: String, completion: @escaping (Result<Void, Error>) -> Void) {
|
2019-05-07 00:34:41 +02:00
|
|
|
|
delegate.renameFolder(for: self, with: folder, to: name, completion: completion)
|
2019-05-06 17:53:20 +02:00
|
|
|
|
}
|
2017-09-25 22:31:36 +02:00
|
|
|
|
|
2019-05-09 23:09:21 +02:00
|
|
|
|
public func restoreFolder(_ folder: Folder, completion: @escaping (Result<Void, Error>) -> Void) {
|
|
|
|
|
delegate.restoreFolder(for: self, folder: folder, completion: completion)
|
|
|
|
|
}
|
|
|
|
|
|
2019-11-15 03:11:41 +01:00
|
|
|
|
func clearWebFeedMetadata(_ feed: WebFeed) {
|
|
|
|
|
webFeedMetadata[feed.url] = nil
|
2019-05-31 14:47:05 +02:00
|
|
|
|
}
|
|
|
|
|
|
2019-05-09 23:09:21 +02:00
|
|
|
|
func addFolder(_ folder: Folder) {
|
|
|
|
|
folders!.insert(folder)
|
|
|
|
|
postChildrenDidChangeNotification()
|
|
|
|
|
structureDidChange()
|
|
|
|
|
}
|
|
|
|
|
|
2019-12-16 23:59:15 +01:00
|
|
|
|
public func updateUnreadCounts(for webFeeds: Set<WebFeed>, completion: VoidCompletionBlock? = nil) {
|
2020-02-02 00:16:24 +01:00
|
|
|
|
fetchUnreadCounts(for: webFeeds, completion: completion)
|
2017-10-08 10:54:37 +02:00
|
|
|
|
}
|
|
|
|
|
|
2019-12-17 00:55:37 +01:00
|
|
|
|
public func fetchArticles(_ fetchType: FetchType) throws -> Set<Article> {
|
2019-07-06 05:06:31 +02:00
|
|
|
|
switch fetchType {
|
|
|
|
|
case .starred:
|
2019-12-17 02:03:41 +01:00
|
|
|
|
return try fetchStarredArticles()
|
2019-07-06 05:06:31 +02:00
|
|
|
|
case .unread:
|
2019-12-17 00:55:37 +01:00
|
|
|
|
return try fetchUnreadArticles()
|
2019-07-06 05:06:31 +02:00
|
|
|
|
case .today:
|
2019-12-17 00:55:37 +01:00
|
|
|
|
return try fetchTodayArticles()
|
2019-11-22 17:21:30 +01:00
|
|
|
|
case .folder(let folder, let readFilter):
|
|
|
|
|
if readFilter {
|
2019-12-17 00:55:37 +01:00
|
|
|
|
return try fetchUnreadArticles(folder: folder)
|
2019-11-22 17:21:30 +01:00
|
|
|
|
} else {
|
2019-12-17 00:55:37 +01:00
|
|
|
|
return try fetchArticles(folder: folder)
|
2019-11-22 17:21:30 +01:00
|
|
|
|
}
|
2019-11-15 03:11:41 +01:00
|
|
|
|
case .webFeed(let webFeed):
|
2019-12-17 00:55:37 +01:00
|
|
|
|
return try fetchArticles(webFeed: webFeed)
|
2019-07-06 05:06:31 +02:00
|
|
|
|
case .articleIDs(let articleIDs):
|
2019-12-17 00:55:37 +01:00
|
|
|
|
return try fetchArticles(articleIDs: articleIDs)
|
2019-07-06 05:06:31 +02:00
|
|
|
|
case .search(let searchString):
|
2019-12-17 00:55:37 +01:00
|
|
|
|
return try fetchArticlesMatching(searchString)
|
2019-08-31 22:53:47 +02:00
|
|
|
|
case .searchWithArticleIDs(let searchString, let articleIDs):
|
2019-12-17 00:55:37 +01:00
|
|
|
|
return try fetchArticlesMatchingWithArticleIDs(searchString, articleIDs)
|
2018-09-11 07:08:38 +02:00
|
|
|
|
}
|
2019-02-25 04:22:16 +01:00
|
|
|
|
}
|
|
|
|
|
|
2019-12-17 00:55:37 +01:00
|
|
|
|
public func fetchArticlesAsync(_ fetchType: FetchType, _ completion: @escaping ArticleSetResultBlock) {
|
2019-07-06 05:06:31 +02:00
|
|
|
|
switch fetchType {
|
|
|
|
|
case .starred:
|
2019-12-15 02:01:34 +01:00
|
|
|
|
fetchStarredArticlesAsync(completion)
|
2019-07-06 05:06:31 +02:00
|
|
|
|
case .unread:
|
2019-12-15 02:01:34 +01:00
|
|
|
|
fetchUnreadArticlesAsync(completion)
|
2019-07-06 05:06:31 +02:00
|
|
|
|
case .today:
|
2019-12-15 02:01:34 +01:00
|
|
|
|
fetchTodayArticlesAsync(completion)
|
2019-11-22 17:21:30 +01:00
|
|
|
|
case .folder(let folder, let readFilter):
|
|
|
|
|
if readFilter {
|
2019-12-15 02:01:34 +01:00
|
|
|
|
return fetchUnreadArticlesAsync(folder: folder, completion)
|
2019-11-22 17:21:30 +01:00
|
|
|
|
} else {
|
2019-12-15 02:01:34 +01:00
|
|
|
|
return fetchArticlesAsync(folder: folder, completion)
|
2019-11-22 17:21:30 +01:00
|
|
|
|
}
|
2019-11-15 03:11:41 +01:00
|
|
|
|
case .webFeed(let webFeed):
|
2019-12-15 02:01:34 +01:00
|
|
|
|
fetchArticlesAsync(webFeed: webFeed, completion)
|
2019-07-06 05:06:31 +02:00
|
|
|
|
case .articleIDs(let articleIDs):
|
2019-12-15 02:01:34 +01:00
|
|
|
|
fetchArticlesAsync(articleIDs: articleIDs, completion)
|
2019-07-06 05:06:31 +02:00
|
|
|
|
case .search(let searchString):
|
2019-12-15 02:01:34 +01:00
|
|
|
|
fetchArticlesMatchingAsync(searchString, completion)
|
2019-08-31 22:53:47 +02:00
|
|
|
|
case .searchWithArticleIDs(let searchString, let articleIDs):
|
2019-12-15 02:01:34 +01:00
|
|
|
|
return fetchArticlesMatchingWithArticleIDsAsync(searchString, articleIDs, completion)
|
2017-12-26 20:27:55 +01:00
|
|
|
|
}
|
2017-10-09 03:58:15 +02:00
|
|
|
|
}
|
2017-11-19 21:12:43 +01:00
|
|
|
|
|
2019-12-17 00:32:08 +01:00
|
|
|
|
public func fetchUnreadCountForToday(_ completion: @escaping SingleUnreadCountCompletionBlock) {
|
2019-12-15 02:01:34 +01:00
|
|
|
|
database.fetchUnreadCountForToday(for: flattenedWebFeeds().webFeedIDs(), completion: completion)
|
2017-11-19 21:12:43 +01:00
|
|
|
|
}
|
|
|
|
|
|
2019-12-17 00:32:08 +01:00
|
|
|
|
public func fetchUnreadCountForStarredArticles(_ completion: @escaping SingleUnreadCountCompletionBlock) {
|
2019-12-15 02:01:34 +01:00
|
|
|
|
database.fetchStarredAndUnreadCount(for: flattenedWebFeeds().webFeedIDs(), completion: completion)
|
2017-11-20 00:40:02 +01:00
|
|
|
|
}
|
|
|
|
|
|
2019-12-17 00:32:08 +01:00
|
|
|
|
public func fetchUnreadArticleIDs(_ completion: @escaping ArticleIDsCompletionBlock) {
|
2019-12-15 02:01:34 +01:00
|
|
|
|
database.fetchUnreadArticleIDsAsync(webFeedIDs: flattenedWebFeeds().webFeedIDs(), completion: completion)
|
2019-05-14 13:20:53 +02:00
|
|
|
|
}
|
2019-07-08 00:05:36 +02:00
|
|
|
|
|
2019-12-17 00:32:08 +01:00
|
|
|
|
public func fetchStarredArticleIDs(_ completion: @escaping ArticleIDsCompletionBlock) {
|
2019-12-15 02:01:34 +01:00
|
|
|
|
database.fetchStarredArticleIDsAsync(webFeedIDs: flattenedWebFeeds().webFeedIDs(), completion: completion)
|
2019-05-14 13:20:53 +02:00
|
|
|
|
}
|
2019-12-08 07:23:44 +01:00
|
|
|
|
|
2020-01-09 06:24:47 +01:00
|
|
|
|
/// Fetch articleIDs for articles that we should have, but don’t. These articles are not userDeleted, and they are either (starred) or (newer than the article cutoff date).
|
2019-12-18 01:43:08 +01:00
|
|
|
|
public func fetchArticleIDsForStatusesWithoutArticlesNewerThanCutoffDate(_ completion: @escaping ArticleIDsCompletionBlock) {
|
|
|
|
|
database.fetchArticleIDsForStatusesWithoutArticlesNewerThanCutoffDate(completion)
|
|
|
|
|
}
|
|
|
|
|
|
2019-11-15 03:11:41 +01:00
|
|
|
|
public func unreadCount(for webFeed: WebFeed) -> Int {
|
|
|
|
|
return unreadCounts[webFeed.webFeedID] ?? 0
|
2018-09-15 07:06:03 +02:00
|
|
|
|
}
|
|
|
|
|
|
2019-11-15 03:11:41 +01:00
|
|
|
|
public func setUnreadCount(_ unreadCount: Int, for webFeed: WebFeed) {
|
|
|
|
|
unreadCounts[webFeed.webFeedID] = unreadCount
|
2018-09-15 07:06:03 +02:00
|
|
|
|
}
|
|
|
|
|
|
2018-09-17 02:54:42 +02:00
|
|
|
|
public func structureDidChange() {
|
|
|
|
|
// Feeds were added or deleted. Or folders added or deleted.
|
|
|
|
|
// Or feeds inside folders were added or deleted.
|
2020-02-09 22:08:11 +01:00
|
|
|
|
opmlFile.markAsDirty()
|
2019-11-15 03:11:41 +01:00
|
|
|
|
flattenedWebFeedsNeedUpdate = true
|
|
|
|
|
webFeedDictionaryNeedsUpdate = true
|
2018-09-17 02:54:42 +02:00
|
|
|
|
}
|
|
|
|
|
|
2019-12-17 00:32:08 +01:00
|
|
|
|
func update(_ webFeed: WebFeed, with parsedFeed: ParsedFeed, _ completion: @escaping DatabaseCompletionBlock) {
|
2019-10-14 04:02:56 +02:00
|
|
|
|
// Used only by an On My Mac account.
|
2019-11-15 03:11:41 +01:00
|
|
|
|
webFeed.takeSettings(from: parsedFeed)
|
|
|
|
|
let webFeedIDsAndItems = [webFeed.webFeedID: parsedFeed.items]
|
|
|
|
|
update(webFeedIDsAndItems: webFeedIDsAndItems, defaultRead: false, completion: completion)
|
2019-05-13 01:32:32 +02:00
|
|
|
|
}
|
2019-10-14 04:02:56 +02:00
|
|
|
|
|
2019-12-17 00:32:08 +01:00
|
|
|
|
func update(webFeedIDsAndItems: [String: Set<ParsedItem>], defaultRead: Bool, completion: @escaping DatabaseCompletionBlock) {
|
|
|
|
|
precondition(Thread.isMainThread)
|
2019-11-15 03:11:41 +01:00
|
|
|
|
guard !webFeedIDsAndItems.isEmpty else {
|
2019-12-17 00:55:37 +01:00
|
|
|
|
completion(nil)
|
2019-10-14 04:02:56 +02:00
|
|
|
|
return
|
|
|
|
|
}
|
2019-12-17 22:28:04 +01:00
|
|
|
|
|
|
|
|
|
let group = DispatchGroup()
|
|
|
|
|
var possibleError: DatabaseError? = nil
|
|
|
|
|
var newArticles = Set<Article>()
|
|
|
|
|
var updatedArticles = Set<Article>()
|
|
|
|
|
|
|
|
|
|
for (webFeedID, items) in webFeedIDsAndItems {
|
|
|
|
|
|
|
|
|
|
group.enter()
|
|
|
|
|
database.update(webFeedID: webFeedID, items: items, defaultRead: defaultRead) { updateArticlesResult in
|
|
|
|
|
|
|
|
|
|
switch updateArticlesResult {
|
|
|
|
|
case .success(let newAndUpdatedArticles):
|
|
|
|
|
if let articles = newAndUpdatedArticles.newArticles {
|
|
|
|
|
newArticles.formUnion(articles)
|
|
|
|
|
}
|
|
|
|
|
if let articles = newAndUpdatedArticles.updatedArticles {
|
|
|
|
|
updatedArticles.formUnion(articles)
|
2019-12-17 00:32:08 +01:00
|
|
|
|
}
|
2019-12-17 22:28:04 +01:00
|
|
|
|
case .failure(let databaseError):
|
|
|
|
|
possibleError = databaseError
|
2019-12-11 02:17:54 +01:00
|
|
|
|
}
|
2019-12-17 22:28:04 +01:00
|
|
|
|
|
|
|
|
|
group.leave()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
group.notify(queue: DispatchQueue.main) {
|
|
|
|
|
var userInfo = [String: Any]()
|
|
|
|
|
var webFeeds = Set(newArticles.compactMap { $0.webFeed })
|
|
|
|
|
webFeeds.formUnion(Set(updatedArticles.compactMap { $0.webFeed }))
|
|
|
|
|
|
|
|
|
|
if !newArticles.isEmpty {
|
|
|
|
|
self.updateUnreadCounts(for: webFeeds) {
|
|
|
|
|
NotificationCenter.default.post(name: .DownloadArticlesDidUpdateUnreadCounts, object: self, userInfo: nil)
|
2019-12-17 00:32:08 +01:00
|
|
|
|
}
|
2019-12-17 22:28:04 +01:00
|
|
|
|
userInfo[UserInfoKey.newArticles] = newArticles
|
2019-12-17 00:32:08 +01:00
|
|
|
|
}
|
2019-12-17 22:28:04 +01:00
|
|
|
|
|
|
|
|
|
if !updatedArticles.isEmpty {
|
|
|
|
|
userInfo[UserInfoKey.updatedArticles] = updatedArticles
|
2019-12-17 00:32:08 +01:00
|
|
|
|
}
|
2019-12-17 22:28:04 +01:00
|
|
|
|
|
|
|
|
|
userInfo[UserInfoKey.webFeeds] = webFeeds
|
|
|
|
|
NotificationCenter.default.post(name: .AccountDidDownloadArticles, object: self, userInfo: userInfo)
|
|
|
|
|
|
|
|
|
|
completion(possibleError)
|
2019-05-11 19:26:23 +02:00
|
|
|
|
}
|
2019-12-17 22:28:04 +01:00
|
|
|
|
|
2019-05-11 19:26:23 +02:00
|
|
|
|
}
|
2019-05-13 01:32:32 +02:00
|
|
|
|
|
2019-07-08 00:05:36 +02:00
|
|
|
|
@discardableResult
|
2019-12-17 00:55:37 +01:00
|
|
|
|
func update(_ articles: Set<Article>, statusKey: ArticleStatus.Key, flag: Bool) throws -> Set<Article>? {
|
2019-05-14 22:34:05 +02:00
|
|
|
|
// Returns set of Articles whose statuses did change.
|
2019-12-17 00:55:37 +01:00
|
|
|
|
guard !articles.isEmpty, let updatedStatuses = try database.mark(articles, statusKey: statusKey, flag: flag) else {
|
2019-05-14 22:34:05 +02:00
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let updatedArticleIDs = updatedStatuses.articleIDs()
|
|
|
|
|
let updatedArticles = Set(articles.filter{ updatedArticleIDs.contains($0.articleID) })
|
|
|
|
|
|
|
|
|
|
noteStatusesForArticlesDidChange(updatedArticles)
|
|
|
|
|
return updatedArticles
|
|
|
|
|
}
|
|
|
|
|
|
2020-01-10 07:27:29 +01:00
|
|
|
|
/// Make sure statuses exist. Any existing statuses won’t be touched.
|
|
|
|
|
/// All created statuses will be marked as read and not starred.
|
|
|
|
|
/// Sends a .StatusesDidChange notification.
|
|
|
|
|
func createStatusesIfNeeded(articleIDs: Set<String>, completion: DatabaseCompletionBlock? = nil) {
|
|
|
|
|
guard !articleIDs.isEmpty else {
|
|
|
|
|
completion?(nil)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
database.createStatusesIfNeeded(articleIDs: articleIDs) { error in
|
|
|
|
|
if let error = error {
|
|
|
|
|
completion?(error)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
self.noteStatusesForArticleIDsDidChange(articleIDs)
|
|
|
|
|
completion?(nil)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2019-12-17 07:45:59 +01:00
|
|
|
|
/// Mark articleIDs statuses based on statusKey and flag.
|
|
|
|
|
/// Will create statuses in the database and in memory as needed. Sends a .StatusesDidChange notification.
|
|
|
|
|
func mark(articleIDs: Set<String>, statusKey: ArticleStatus.Key, flag: Bool, completion: DatabaseCompletionBlock? = nil) {
|
|
|
|
|
guard !articleIDs.isEmpty else {
|
|
|
|
|
completion?(nil)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
database.mark(articleIDs: articleIDs, statusKey: statusKey, flag: flag) { error in
|
|
|
|
|
if let error = error {
|
|
|
|
|
completion?(error)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
self.noteStatusesForArticleIDsDidChange(articleIDs)
|
|
|
|
|
completion?(nil)
|
|
|
|
|
}
|
2019-12-12 07:28:01 +01:00
|
|
|
|
}
|
|
|
|
|
|
2019-12-17 07:45:59 +01:00
|
|
|
|
/// Mark articleIDs as read. Will create statuses in the database and in memory as needed. Sends a .StatusesDidChange notification.
|
2019-12-17 23:41:45 +01:00
|
|
|
|
func markAsRead(_ articleIDs: Set<String>, completion: DatabaseCompletionBlock? = nil) {
|
|
|
|
|
mark(articleIDs: articleIDs, statusKey: .read, flag: true, completion: completion)
|
2019-12-17 07:45:59 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Mark articleIDs as unread. Will create statuses in the database and in memory as needed. Sends a .StatusesDidChange notification.
|
2019-12-17 23:41:45 +01:00
|
|
|
|
func markAsUnread(_ articleIDs: Set<String>, completion: DatabaseCompletionBlock? = nil) {
|
|
|
|
|
mark(articleIDs: articleIDs, statusKey: .read, flag: false, completion: completion)
|
2019-12-17 07:45:59 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Mark articleIDs as starred. Will create statuses in the database and in memory as needed. Sends a .StatusesDidChange notification.
|
2019-12-17 23:41:45 +01:00
|
|
|
|
func markAsStarred(_ articleIDs: Set<String>, completion: DatabaseCompletionBlock? = nil) {
|
|
|
|
|
mark(articleIDs: articleIDs, statusKey: .starred, flag: true, completion: completion)
|
2019-12-17 07:45:59 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Mark articleIDs as unstarred. Will create statuses in the database and in memory as needed. Sends a .StatusesDidChange notification.
|
2019-12-17 23:41:45 +01:00
|
|
|
|
func markAsUnstarred(_ articleIDs: Set<String>, completion: DatabaseCompletionBlock? = nil) {
|
|
|
|
|
mark(articleIDs: articleIDs, statusKey: .starred, flag: false, completion: completion)
|
2019-12-17 01:26:35 +01:00
|
|
|
|
}
|
|
|
|
|
|
2019-10-13 00:06:21 +02:00
|
|
|
|
/// Empty caches that can reasonably be emptied. Call when the app goes in the background, for instance.
|
|
|
|
|
func emptyCaches() {
|
|
|
|
|
database.emptyCaches()
|
|
|
|
|
}
|
|
|
|
|
|
2018-09-17 02:54:42 +02:00
|
|
|
|
// MARK: - Container
|
|
|
|
|
|
2019-11-15 03:11:41 +01:00
|
|
|
|
public func flattenedWebFeeds() -> Set<WebFeed> {
|
2019-07-07 23:01:44 +02:00
|
|
|
|
assert(Thread.isMainThread)
|
2019-11-15 03:11:41 +01:00
|
|
|
|
if flattenedWebFeedsNeedUpdate {
|
|
|
|
|
updateFlattenedWebFeeds()
|
2018-09-17 02:54:42 +02:00
|
|
|
|
}
|
2019-11-15 03:11:41 +01:00
|
|
|
|
return _flattenedWebFeeds
|
2018-09-17 02:54:42 +02:00
|
|
|
|
}
|
|
|
|
|
|
2019-11-15 03:11:41 +01:00
|
|
|
|
public func removeWebFeed(_ webFeed: WebFeed) {
|
|
|
|
|
topLevelWebFeeds.remove(webFeed)
|
2019-05-08 00:41:32 +02:00
|
|
|
|
structureDidChange()
|
2019-10-03 10:45:16 +02:00
|
|
|
|
postChildrenDidChangeNotification()
|
|
|
|
|
}
|
|
|
|
|
|
2019-11-15 03:11:41 +01:00
|
|
|
|
public func removeFeeds(_ webFeeds: Set<WebFeed>) {
|
|
|
|
|
guard !webFeeds.isEmpty else {
|
2019-10-03 10:45:16 +02:00
|
|
|
|
return
|
|
|
|
|
}
|
2019-11-15 03:11:41 +01:00
|
|
|
|
topLevelWebFeeds.subtract(webFeeds)
|
2019-10-03 10:45:16 +02:00
|
|
|
|
structureDidChange()
|
2019-05-08 00:41:32 +02:00
|
|
|
|
postChildrenDidChangeNotification()
|
|
|
|
|
}
|
|
|
|
|
|
2019-11-15 03:11:41 +01:00
|
|
|
|
public func addWebFeed(_ webFeed: WebFeed) {
|
|
|
|
|
topLevelWebFeeds.insert(webFeed)
|
2018-09-17 02:54:42 +02:00
|
|
|
|
structureDidChange()
|
|
|
|
|
postChildrenDidChangeNotification()
|
|
|
|
|
}
|
|
|
|
|
|
2019-11-15 03:11:41 +01:00
|
|
|
|
func addFeedIfNotInAnyFolder(_ webFeed: WebFeed) {
|
|
|
|
|
if !flattenedWebFeeds().contains(webFeed) {
|
|
|
|
|
addWebFeed(webFeed)
|
2019-05-28 20:38:40 +02:00
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2019-05-30 03:53:00 +02:00
|
|
|
|
func removeFolder(_ folder: Folder) {
|
2018-09-17 02:54:42 +02:00
|
|
|
|
folders?.remove(folder)
|
|
|
|
|
structureDidChange()
|
|
|
|
|
postChildrenDidChangeNotification()
|
|
|
|
|
}
|
2019-05-07 00:34:41 +02:00
|
|
|
|
|
2017-11-25 20:13:15 +01:00
|
|
|
|
// MARK: - Debug
|
|
|
|
|
|
|
|
|
|
public func debugDropConditionalGetInfo() {
|
|
|
|
|
#if DEBUG
|
2019-11-15 03:11:41 +01:00
|
|
|
|
flattenedWebFeeds().forEach{ $0.debugDropConditionalGetInfo() }
|
2017-11-25 20:13:15 +01:00
|
|
|
|
#endif
|
|
|
|
|
}
|
|
|
|
|
|
2019-02-19 07:29:43 +01:00
|
|
|
|
public func debugRunSearch() {
|
|
|
|
|
#if DEBUG
|
2019-02-25 04:22:16 +01:00
|
|
|
|
let t1 = Date()
|
2019-12-17 02:03:41 +01:00
|
|
|
|
let articles = try! fetchArticlesMatching("Brent NetNewsWire")
|
2019-02-25 04:22:16 +01:00
|
|
|
|
let t2 = Date()
|
|
|
|
|
print(t2.timeIntervalSince(t1))
|
|
|
|
|
print(articles.count)
|
2019-02-19 07:29:43 +01:00
|
|
|
|
#endif
|
|
|
|
|
}
|
|
|
|
|
|
2017-10-08 02:43:10 +02:00
|
|
|
|
// MARK: - Notifications
|
|
|
|
|
|
|
|
|
|
@objc func downloadProgressDidChange(_ note: Notification) {
|
|
|
|
|
guard let noteObject = note.object as? DownloadProgress, noteObject === refreshProgress else {
|
|
|
|
|
return
|
|
|
|
|
}
|
2017-10-08 02:20:19 +02:00
|
|
|
|
|
|
|
|
|
refreshInProgress = refreshProgress.numberRemaining > 0
|
|
|
|
|
NotificationCenter.default.post(name: .AccountRefreshProgressDidChange, object: self)
|
|
|
|
|
}
|
2017-10-13 06:02:27 +02:00
|
|
|
|
|
|
|
|
|
@objc func unreadCountDidChange(_ note: Notification) {
|
2019-11-15 03:11:41 +01:00
|
|
|
|
if let feed = note.object as? WebFeed, feed.account === self {
|
2018-09-14 07:25:10 +02:00
|
|
|
|
updateUnreadCount()
|
2017-10-13 15:50:33 +02:00
|
|
|
|
}
|
2017-10-13 06:02:27 +02:00
|
|
|
|
}
|
2017-11-15 22:26:10 +01:00
|
|
|
|
|
|
|
|
|
@objc func batchUpdateDidPerform(_ note: Notification) {
|
2019-11-15 03:11:41 +01:00
|
|
|
|
flattenedWebFeedsNeedUpdate = true
|
|
|
|
|
rebuildWebFeedDictionaries()
|
2017-11-15 22:26:10 +01:00
|
|
|
|
updateUnreadCount()
|
|
|
|
|
}
|
2017-10-08 02:20:19 +02:00
|
|
|
|
|
2018-02-25 00:54:32 +01:00
|
|
|
|
@objc func childrenDidChange(_ note: Notification) {
|
|
|
|
|
guard let object = note.object else {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if let account = object as? Account, account === self {
|
2018-09-17 02:54:42 +02:00
|
|
|
|
structureDidChange()
|
2019-05-16 16:54:19 +02:00
|
|
|
|
updateUnreadCount()
|
2018-02-25 00:54:32 +01:00
|
|
|
|
}
|
|
|
|
|
if let folder = object as? Folder, folder.account === self {
|
2018-09-17 02:54:42 +02:00
|
|
|
|
structureDidChange()
|
2018-02-25 00:54:32 +01:00
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2018-01-24 06:49:33 +01:00
|
|
|
|
@objc func displayNameDidChange(_ note: Notification) {
|
2018-02-25 00:54:32 +01:00
|
|
|
|
if let folder = note.object as? Folder, folder.account === self {
|
2018-09-17 02:54:42 +02:00
|
|
|
|
structureDidChange()
|
2018-01-24 06:49:33 +01:00
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2018-08-25 20:54:58 +02:00
|
|
|
|
// MARK: - Hashable
|
|
|
|
|
|
|
|
|
|
public func hash(into hasher: inout Hasher) {
|
|
|
|
|
hasher.combine(accountID)
|
|
|
|
|
}
|
|
|
|
|
|
2017-09-17 20:32:58 +02:00
|
|
|
|
// MARK: - Equatable
|
2017-09-17 00:30:26 +02:00
|
|
|
|
|
2017-09-17 20:32:58 +02:00
|
|
|
|
public class func ==(lhs: Account, rhs: Account) -> Bool {
|
|
|
|
|
return lhs === rhs
|
2017-09-17 00:30:26 +02:00
|
|
|
|
}
|
2017-07-03 19:29:44 +02:00
|
|
|
|
}
|
|
|
|
|
|
2019-05-05 14:49:59 +02:00
|
|
|
|
// MARK: - AccountMetadataDelegate
|
2019-05-05 14:21:26 +02:00
|
|
|
|
|
2019-05-05 14:49:59 +02:00
|
|
|
|
extension Account: AccountMetadataDelegate {
|
|
|
|
|
func valueDidChange(_ accountMetadata: AccountMetadata, key: AccountMetadata.CodingKeys) {
|
2019-09-13 23:12:19 +02:00
|
|
|
|
metadataFile.markAsDirty()
|
2019-05-05 14:21:26 +02:00
|
|
|
|
}
|
|
|
|
|
}
|
2017-07-03 19:29:44 +02:00
|
|
|
|
|
2019-03-14 07:41:43 +01:00
|
|
|
|
// MARK: - FeedMetadataDelegate
|
|
|
|
|
|
2019-11-15 03:11:41 +01:00
|
|
|
|
extension Account: WebFeedMetadataDelegate {
|
2019-03-14 07:41:43 +01:00
|
|
|
|
|
2019-11-15 03:11:41 +01:00
|
|
|
|
func valueDidChange(_ feedMetadata: WebFeedMetadata, key: WebFeedMetadata.CodingKeys) {
|
|
|
|
|
webFeedMetadataFile.markAsDirty()
|
|
|
|
|
guard let feed = existingWebFeed(withWebFeedID: feedMetadata.webFeedID) else {
|
2019-03-17 20:47:04 +01:00
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
feed.postFeedSettingDidChangeNotification(key)
|
2019-03-14 07:41:43 +01:00
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2019-07-06 05:06:31 +02:00
|
|
|
|
// MARK: - Fetching (Private)
|
|
|
|
|
|
|
|
|
|
private extension Account {
|
|
|
|
|
|
2019-12-17 02:03:41 +01:00
|
|
|
|
func fetchStarredArticles() throws -> Set<Article> {
|
|
|
|
|
return try database.fetchStarredArticles(flattenedWebFeeds().webFeedIDs())
|
2019-07-06 05:06:31 +02:00
|
|
|
|
}
|
|
|
|
|
|
2019-12-17 00:55:37 +01:00
|
|
|
|
func fetchStarredArticlesAsync(_ completion: @escaping ArticleSetResultBlock) {
|
2019-12-15 02:01:34 +01:00
|
|
|
|
database.fetchedStarredArticlesAsync(flattenedWebFeeds().webFeedIDs(), completion)
|
2019-07-06 05:06:31 +02:00
|
|
|
|
}
|
|
|
|
|
|
2019-12-17 00:32:08 +01:00
|
|
|
|
func fetchUnreadArticles() throws -> Set<Article> {
|
|
|
|
|
return try fetchUnreadArticles(forContainer: self)
|
2019-07-06 05:06:31 +02:00
|
|
|
|
}
|
|
|
|
|
|
2019-12-17 00:55:37 +01:00
|
|
|
|
func fetchUnreadArticlesAsync(_ completion: @escaping ArticleSetResultBlock) {
|
2019-12-15 02:01:34 +01:00
|
|
|
|
fetchUnreadArticlesAsync(forContainer: self, completion)
|
2019-07-06 05:06:31 +02:00
|
|
|
|
}
|
|
|
|
|
|
2019-12-17 00:32:08 +01:00
|
|
|
|
func fetchTodayArticles() throws -> Set<Article> {
|
|
|
|
|
return try database.fetchTodayArticles(flattenedWebFeeds().webFeedIDs())
|
2019-07-06 05:06:31 +02:00
|
|
|
|
}
|
|
|
|
|
|
2019-12-17 00:55:37 +01:00
|
|
|
|
func fetchTodayArticlesAsync(_ completion: @escaping ArticleSetResultBlock) {
|
2019-12-15 02:01:34 +01:00
|
|
|
|
database.fetchTodayArticlesAsync(flattenedWebFeeds().webFeedIDs(), completion)
|
2019-07-06 05:06:31 +02:00
|
|
|
|
}
|
|
|
|
|
|
2019-12-17 00:32:08 +01:00
|
|
|
|
func fetchArticles(folder: Folder) throws -> Set<Article> {
|
|
|
|
|
return try fetchArticles(forContainer: folder)
|
2019-07-06 05:06:31 +02:00
|
|
|
|
}
|
|
|
|
|
|
2019-12-17 00:55:37 +01:00
|
|
|
|
func fetchArticlesAsync(folder: Folder, _ completion: @escaping ArticleSetResultBlock) {
|
2019-12-15 02:01:34 +01:00
|
|
|
|
fetchArticlesAsync(forContainer: folder, completion)
|
2019-11-22 17:21:30 +01:00
|
|
|
|
}
|
|
|
|
|
|
2019-12-17 00:32:08 +01:00
|
|
|
|
func fetchUnreadArticles(folder: Folder) throws -> Set<Article> {
|
|
|
|
|
return try fetchUnreadArticles(forContainer: folder)
|
2019-11-22 17:21:30 +01:00
|
|
|
|
}
|
|
|
|
|
|
2019-12-17 00:55:37 +01:00
|
|
|
|
func fetchUnreadArticlesAsync(folder: Folder, _ completion: @escaping ArticleSetResultBlock) {
|
2019-12-15 02:01:34 +01:00
|
|
|
|
fetchUnreadArticlesAsync(forContainer: folder, completion)
|
2019-07-06 05:06:31 +02:00
|
|
|
|
}
|
|
|
|
|
|
2019-12-17 00:32:08 +01:00
|
|
|
|
func fetchArticles(webFeed: WebFeed) throws -> Set<Article> {
|
|
|
|
|
let articles = try database.fetchArticles(webFeed.webFeedID)
|
2019-11-15 03:11:41 +01:00
|
|
|
|
validateUnreadCount(webFeed, articles)
|
2019-07-06 05:06:31 +02:00
|
|
|
|
return articles
|
|
|
|
|
}
|
|
|
|
|
|
2019-12-17 00:55:37 +01:00
|
|
|
|
func fetchArticlesAsync(webFeed: WebFeed, _ completion: @escaping ArticleSetResultBlock) {
|
2019-12-17 02:03:41 +01:00
|
|
|
|
database.fetchArticlesAsync(webFeed.webFeedID) { [weak self] articleSetResult in
|
|
|
|
|
switch articleSetResult {
|
|
|
|
|
case .success(let articles):
|
|
|
|
|
self?.validateUnreadCount(webFeed, articles)
|
|
|
|
|
completion(.success(articles))
|
|
|
|
|
case .failure(let databaseError):
|
|
|
|
|
completion(.failure(databaseError))
|
|
|
|
|
}
|
2019-07-06 05:06:31 +02:00
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2019-12-17 00:32:08 +01:00
|
|
|
|
func fetchArticlesMatching(_ searchString: String) throws -> Set<Article> {
|
|
|
|
|
return try database.fetchArticlesMatching(searchString, flattenedWebFeeds().webFeedIDs())
|
2019-07-06 05:06:31 +02:00
|
|
|
|
}
|
|
|
|
|
|
2019-12-17 00:32:08 +01:00
|
|
|
|
func fetchArticlesMatchingWithArticleIDs(_ searchString: String, _ articleIDs: Set<String>) throws -> Set<Article> {
|
|
|
|
|
return try database.fetchArticlesMatchingWithArticleIDs(searchString, articleIDs)
|
2019-08-31 22:53:47 +02:00
|
|
|
|
}
|
|
|
|
|
|
2019-12-17 00:55:37 +01:00
|
|
|
|
func fetchArticlesMatchingAsync(_ searchString: String, _ completion: @escaping ArticleSetResultBlock) {
|
2019-12-15 02:01:34 +01:00
|
|
|
|
database.fetchArticlesMatchingAsync(searchString, flattenedWebFeeds().webFeedIDs(), completion)
|
2019-07-06 05:06:31 +02:00
|
|
|
|
}
|
|
|
|
|
|
2019-12-17 00:55:37 +01:00
|
|
|
|
func fetchArticlesMatchingWithArticleIDsAsync(_ searchString: String, _ articleIDs: Set<String>, _ completion: @escaping ArticleSetResultBlock) {
|
2019-12-15 02:01:34 +01:00
|
|
|
|
database.fetchArticlesMatchingWithArticleIDsAsync(searchString, articleIDs, completion)
|
2019-08-31 22:53:47 +02:00
|
|
|
|
}
|
|
|
|
|
|
2019-12-17 00:32:08 +01:00
|
|
|
|
func fetchArticles(articleIDs: Set<String>) throws -> Set<Article> {
|
|
|
|
|
return try database.fetchArticles(articleIDs: articleIDs)
|
2019-07-06 05:06:31 +02:00
|
|
|
|
}
|
|
|
|
|
|
2019-12-17 00:55:37 +01:00
|
|
|
|
func fetchArticlesAsync(articleIDs: Set<String>, _ completion: @escaping ArticleSetResultBlock) {
|
2019-12-15 02:01:34 +01:00
|
|
|
|
return database.fetchArticlesAsync(articleIDs: articleIDs, completion)
|
2019-07-06 05:06:31 +02:00
|
|
|
|
}
|
|
|
|
|
|
2019-12-17 00:32:08 +01:00
|
|
|
|
func fetchUnreadArticles(webFeed: WebFeed) throws -> Set<Article> {
|
|
|
|
|
let articles = try database.fetchUnreadArticles(Set([webFeed.webFeedID]))
|
2019-11-15 03:11:41 +01:00
|
|
|
|
validateUnreadCount(webFeed, articles)
|
2019-07-06 05:06:31 +02:00
|
|
|
|
return articles
|
|
|
|
|
}
|
|
|
|
|
|
2019-12-15 02:01:34 +01:00
|
|
|
|
func fetchUnreadArticlesAsync(for webFeed: WebFeed, completion: @escaping (Set<Article>) -> Void) {
|
2019-07-06 05:06:31 +02:00
|
|
|
|
// database.fetchUnreadArticlesAsync(for: Set([feed.feedID])) { [weak self] (articles) in
|
|
|
|
|
// self?.validateUnreadCount(feed, articles)
|
|
|
|
|
// callback(articles)
|
|
|
|
|
// }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
2019-12-17 00:32:08 +01:00
|
|
|
|
func fetchArticles(forContainer container: Container) throws -> Set<Article> {
|
2019-11-22 17:21:30 +01:00
|
|
|
|
let feeds = container.flattenedWebFeeds()
|
2019-12-17 00:32:08 +01:00
|
|
|
|
let articles = try database.fetchArticles(feeds.webFeedIDs())
|
2019-11-22 17:21:30 +01:00
|
|
|
|
validateUnreadCountsAfterFetchingUnreadArticles(feeds, articles)
|
|
|
|
|
return articles
|
|
|
|
|
}
|
|
|
|
|
|
2019-12-17 00:55:37 +01:00
|
|
|
|
func fetchArticlesAsync(forContainer container: Container, _ completion: @escaping ArticleSetResultBlock) {
|
2019-11-22 17:21:30 +01:00
|
|
|
|
let webFeeds = container.flattenedWebFeeds()
|
2019-12-17 02:03:41 +01:00
|
|
|
|
database.fetchArticlesAsync(webFeeds.webFeedIDs()) { [weak self] (articleSetResult) in
|
|
|
|
|
switch articleSetResult {
|
|
|
|
|
case .success(let articles):
|
|
|
|
|
self?.validateUnreadCountsAfterFetchingUnreadArticles(webFeeds, articles)
|
|
|
|
|
completion(.success(articles))
|
|
|
|
|
case .failure(let databaseError):
|
|
|
|
|
completion(.failure(databaseError))
|
|
|
|
|
}
|
2019-11-22 17:21:30 +01:00
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2019-12-17 00:32:08 +01:00
|
|
|
|
func fetchUnreadArticles(forContainer container: Container) throws -> Set<Article> {
|
2019-11-15 03:11:41 +01:00
|
|
|
|
let feeds = container.flattenedWebFeeds()
|
2019-12-17 00:32:08 +01:00
|
|
|
|
let articles = try database.fetchUnreadArticles(feeds.webFeedIDs())
|
2019-07-06 05:06:31 +02:00
|
|
|
|
validateUnreadCountsAfterFetchingUnreadArticles(feeds, articles)
|
|
|
|
|
return articles
|
|
|
|
|
}
|
|
|
|
|
|
2019-12-17 00:55:37 +01:00
|
|
|
|
func fetchUnreadArticlesAsync(forContainer container: Container, _ completion: @escaping ArticleSetResultBlock) {
|
2019-11-15 03:11:41 +01:00
|
|
|
|
let webFeeds = container.flattenedWebFeeds()
|
2019-12-17 02:03:41 +01:00
|
|
|
|
database.fetchUnreadArticlesAsync(webFeeds.webFeedIDs()) { [weak self] (articleSetResult) in
|
|
|
|
|
switch articleSetResult {
|
|
|
|
|
case .success(let articles):
|
|
|
|
|
self?.validateUnreadCountsAfterFetchingUnreadArticles(webFeeds, articles)
|
|
|
|
|
completion(.success(articles))
|
|
|
|
|
case .failure(let databaseError):
|
|
|
|
|
completion(.failure(databaseError))
|
|
|
|
|
}
|
2019-07-06 05:06:31 +02:00
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2019-11-15 03:11:41 +01:00
|
|
|
|
func validateUnreadCountsAfterFetchingUnreadArticles(_ webFeeds: Set<WebFeed>, _ articles: Set<Article>) {
|
2019-07-06 05:06:31 +02:00
|
|
|
|
// Validate unread counts. This was the site of a performance slowdown:
|
|
|
|
|
// it was calling going through the entire list of articles once per feed:
|
|
|
|
|
// feeds.forEach { validateUnreadCount($0, articles) }
|
|
|
|
|
// Now we loop through articles exactly once. This makes a huge difference.
|
|
|
|
|
|
2019-11-15 03:11:41 +01:00
|
|
|
|
var unreadCountStorage = [String: Int]() // [WebFeedID: Int]
|
2019-07-06 20:50:22 +02:00
|
|
|
|
for article in articles where !article.status.read {
|
2019-11-15 03:11:41 +01:00
|
|
|
|
unreadCountStorage[article.webFeedID, default: 0] += 1
|
2019-07-06 05:06:31 +02:00
|
|
|
|
}
|
2019-11-15 03:11:41 +01:00
|
|
|
|
webFeeds.forEach { (webFeed) in
|
|
|
|
|
let unreadCount = unreadCountStorage[webFeed.webFeedID, default: 0]
|
|
|
|
|
webFeed.unreadCount = unreadCount
|
2019-07-06 05:06:31 +02:00
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2019-11-15 03:11:41 +01:00
|
|
|
|
func validateUnreadCount(_ webFeed: WebFeed, _ articles: Set<Article>) {
|
2019-07-06 05:06:31 +02:00
|
|
|
|
// articles must contain all the unread articles for the feed.
|
|
|
|
|
// The unread number should match the feed’s unread count.
|
|
|
|
|
|
|
|
|
|
let feedUnreadCount = articles.reduce(0) { (result, article) -> Int in
|
2019-11-15 03:11:41 +01:00
|
|
|
|
if article.webFeed == webFeed && !article.status.read {
|
2019-07-06 05:06:31 +02:00
|
|
|
|
return result + 1
|
|
|
|
|
}
|
|
|
|
|
return result
|
|
|
|
|
}
|
|
|
|
|
|
2019-11-15 03:11:41 +01:00
|
|
|
|
webFeed.unreadCount = feedUnreadCount
|
2019-07-06 05:06:31 +02:00
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2017-10-01 19:59:35 +02:00
|
|
|
|
// MARK: - Private
|
2017-09-27 22:29:05 +02:00
|
|
|
|
|
|
|
|
|
private extension Account {
|
|
|
|
|
|
2019-11-15 03:11:41 +01:00
|
|
|
|
func webFeedMetadata(feedURL: String, webFeedID: String) -> WebFeedMetadata {
|
|
|
|
|
if let d = webFeedMetadata[feedURL] {
|
2019-03-17 01:30:30 +01:00
|
|
|
|
assert(d.delegate === self)
|
|
|
|
|
return d
|
|
|
|
|
}
|
2019-11-15 03:11:41 +01:00
|
|
|
|
let d = WebFeedMetadata(webFeedID: webFeedID)
|
2019-03-17 01:30:30 +01:00
|
|
|
|
d.delegate = self
|
2019-11-15 03:11:41 +01:00
|
|
|
|
webFeedMetadata[feedURL] = d
|
2019-03-17 01:30:30 +01:00
|
|
|
|
return d
|
|
|
|
|
}
|
|
|
|
|
|
2019-11-15 03:11:41 +01:00
|
|
|
|
func updateFlattenedWebFeeds() {
|
|
|
|
|
var feeds = Set<WebFeed>()
|
|
|
|
|
feeds.formUnion(topLevelWebFeeds)
|
2018-09-17 02:54:42 +02:00
|
|
|
|
for folder in folders! {
|
2019-11-15 03:11:41 +01:00
|
|
|
|
feeds.formUnion(folder.flattenedWebFeeds())
|
2018-09-17 02:54:42 +02:00
|
|
|
|
}
|
|
|
|
|
|
2019-11-15 03:11:41 +01:00
|
|
|
|
_flattenedWebFeeds = feeds
|
|
|
|
|
flattenedWebFeedsNeedUpdate = false
|
2018-09-17 02:54:42 +02:00
|
|
|
|
}
|
|
|
|
|
|
2019-11-15 03:11:41 +01:00
|
|
|
|
func rebuildWebFeedDictionaries() {
|
|
|
|
|
var idDictionary = [String: WebFeed]()
|
2017-10-22 20:08:51 +02:00
|
|
|
|
|
2019-11-15 03:11:41 +01:00
|
|
|
|
flattenedWebFeeds().forEach { (feed) in
|
|
|
|
|
idDictionary[feed.webFeedID] = feed
|
2017-10-22 20:08:51 +02:00
|
|
|
|
}
|
|
|
|
|
|
2019-11-15 03:11:41 +01:00
|
|
|
|
_idToWebFeedDictionary = idDictionary
|
|
|
|
|
webFeedDictionaryNeedsUpdate = false
|
2017-10-01 19:59:35 +02:00
|
|
|
|
}
|
2017-10-10 22:23:12 +02:00
|
|
|
|
|
|
|
|
|
func updateUnreadCount() {
|
2018-09-17 02:54:42 +02:00
|
|
|
|
if fetchingAllUnreadCounts {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
var updatedUnreadCount = 0
|
2019-11-15 03:11:41 +01:00
|
|
|
|
for feed in flattenedWebFeeds() {
|
2018-09-17 02:54:42 +02:00
|
|
|
|
updatedUnreadCount += feed.unreadCount
|
|
|
|
|
}
|
|
|
|
|
unreadCount = updatedUnreadCount
|
2017-10-10 22:23:12 +02:00
|
|
|
|
}
|
|
|
|
|
|
2017-10-13 06:02:27 +02:00
|
|
|
|
func noteStatusesForArticlesDidChange(_ articles: Set<Article>) {
|
2019-11-15 03:11:41 +01:00
|
|
|
|
let feeds = Set(articles.compactMap { $0.webFeed })
|
2017-10-13 06:02:27 +02:00
|
|
|
|
let statuses = Set(articles.map { $0.status })
|
2019-12-17 07:45:59 +01:00
|
|
|
|
let articleIDs = Set(articles.map { $0.articleID })
|
|
|
|
|
|
2017-10-10 22:23:12 +02:00
|
|
|
|
// .UnreadCountDidChange notification will get sent to Folder and Account objects,
|
|
|
|
|
// which will update their own unread counts.
|
|
|
|
|
updateUnreadCounts(for: feeds)
|
|
|
|
|
|
2019-12-17 07:45:59 +01:00
|
|
|
|
NotificationCenter.default.post(name: .StatusesDidChange, object: self, userInfo: [UserInfoKey.statuses: statuses, UserInfoKey.articles: articles, UserInfoKey.articleIDs: articleIDs, UserInfoKey.webFeeds: feeds])
|
2017-10-10 22:23:12 +02:00
|
|
|
|
}
|
2017-12-03 20:57:53 +01:00
|
|
|
|
|
2019-12-17 07:45:59 +01:00
|
|
|
|
func noteStatusesForArticleIDsDidChange(_ articleIDs: Set<String>) {
|
|
|
|
|
fetchAllUnreadCounts()
|
|
|
|
|
NotificationCenter.default.post(name: .StatusesDidChange, object: self, userInfo: [UserInfoKey.articleIDs: articleIDs])
|
|
|
|
|
}
|
|
|
|
|
|
2020-02-02 00:01:47 +01:00
|
|
|
|
/// Fetch unread counts for zero or more feeds.
|
|
|
|
|
///
|
|
|
|
|
/// Uses the most efficient method based on how many feeds were passed in.
|
2020-02-02 00:16:24 +01:00
|
|
|
|
func fetchUnreadCounts(for feeds: Set<WebFeed>, completion: VoidCompletionBlock?) {
|
2020-02-02 00:01:47 +01:00
|
|
|
|
if feeds.isEmpty {
|
|
|
|
|
completion?()
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if feeds.count == 1, let feed = feeds.first {
|
|
|
|
|
fetchUnreadCount(feed, completion)
|
|
|
|
|
}
|
|
|
|
|
else if feeds.count < 10 {
|
|
|
|
|
fetchUnreadCounts(feeds, completion)
|
|
|
|
|
}
|
|
|
|
|
else {
|
2020-02-06 07:17:32 +01:00
|
|
|
|
fetchAllUnreadCounts(completion)
|
2020-02-02 00:01:47 +01:00
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2020-01-30 08:09:38 +01:00
|
|
|
|
func fetchUnreadCount(_ feed: WebFeed, _ completion: VoidCompletionBlock?) {
|
2020-02-06 07:17:32 +01:00
|
|
|
|
database.fetchUnreadCount(feed.webFeedID) { result in
|
|
|
|
|
if let unreadCount = try? result.get() {
|
2020-01-30 08:09:38 +01:00
|
|
|
|
feed.unreadCount = unreadCount
|
|
|
|
|
}
|
|
|
|
|
completion?()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2020-02-02 00:01:47 +01:00
|
|
|
|
func fetchUnreadCounts(_ feeds: Set<WebFeed>, _ completion: VoidCompletionBlock?) {
|
2020-02-06 07:17:32 +01:00
|
|
|
|
let webFeedIDs = Set(feeds.map { $0.webFeedID })
|
|
|
|
|
database.fetchUnreadCounts(for: webFeedIDs) { result in
|
|
|
|
|
if let unreadCountDictionary = try? result.get() {
|
2020-02-02 00:01:47 +01:00
|
|
|
|
self.processUnreadCounts(unreadCountDictionary: unreadCountDictionary, feeds: feeds)
|
|
|
|
|
}
|
|
|
|
|
completion?()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2020-02-06 07:17:32 +01:00
|
|
|
|
func fetchAllUnreadCounts(_ completion: VoidCompletionBlock? = nil) {
|
2018-09-17 02:54:42 +02:00
|
|
|
|
fetchingAllUnreadCounts = true
|
2020-02-06 07:17:32 +01:00
|
|
|
|
database.fetchAllUnreadCounts { result in
|
2020-02-06 06:23:23 +01:00
|
|
|
|
guard let unreadCountDictionary = try? result.get() else {
|
2020-02-06 07:17:32 +01:00
|
|
|
|
completion?()
|
2020-01-28 08:00:48 +01:00
|
|
|
|
return
|
2017-12-03 20:57:53 +01:00
|
|
|
|
}
|
2020-02-02 00:01:47 +01:00
|
|
|
|
self.processUnreadCounts(unreadCountDictionary: unreadCountDictionary, feeds: self.flattenedWebFeeds())
|
2020-01-28 08:00:48 +01:00
|
|
|
|
|
|
|
|
|
self.fetchingAllUnreadCounts = false
|
|
|
|
|
self.updateUnreadCount()
|
2020-02-06 06:23:23 +01:00
|
|
|
|
|
2020-02-03 19:28:34 +01:00
|
|
|
|
if !self.isUnreadCountsInitialized {
|
|
|
|
|
self.isUnreadCountsInitialized = true
|
|
|
|
|
self.postUnreadCountDidInitializeNotification()
|
|
|
|
|
}
|
2020-02-06 07:17:32 +01:00
|
|
|
|
completion?()
|
2020-01-28 08:00:48 +01:00
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2020-02-02 00:01:47 +01:00
|
|
|
|
func processUnreadCounts(unreadCountDictionary: UnreadCountDictionary, feeds: Set<WebFeed>) {
|
|
|
|
|
for feed in feeds {
|
2020-01-28 08:00:48 +01:00
|
|
|
|
// When the unread count is zero, it won’t appear in unreadCountDictionary.
|
|
|
|
|
let unreadCount = unreadCountDictionary[feed.webFeedID] ?? 0
|
|
|
|
|
feed.unreadCount = unreadCount
|
2017-12-03 20:57:53 +01:00
|
|
|
|
}
|
|
|
|
|
}
|
2017-07-03 19:29:44 +02:00
|
|
|
|
}
|
|
|
|
|
|
2017-10-22 20:08:51 +02:00
|
|
|
|
// MARK: - Container Overrides
|
|
|
|
|
|
|
|
|
|
extension Account {
|
|
|
|
|
|
2019-11-15 03:11:41 +01:00
|
|
|
|
public func existingWebFeed(withWebFeedID webFeedID: String) -> WebFeed? {
|
|
|
|
|
return idToWebFeedDictionary[webFeedID]
|
2017-10-22 20:08:51 +02:00
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2017-09-27 22:29:05 +02:00
|
|
|
|
// MARK: - OPMLRepresentable
|
|
|
|
|
|
2017-09-17 00:25:38 +02:00
|
|
|
|
extension Account: OPMLRepresentable {
|
|
|
|
|
|
2020-01-14 05:20:20 +01:00
|
|
|
|
public func OPMLString(indentLevel: Int, allowCustomAttributes: Bool) -> String {
|
2017-09-17 00:25:38 +02:00
|
|
|
|
var s = ""
|
2020-01-06 21:58:51 +01:00
|
|
|
|
for feed in topLevelWebFeeds.sorted() {
|
2020-01-14 05:20:20 +01:00
|
|
|
|
s += feed.OPMLString(indentLevel: indentLevel + 1, allowCustomAttributes: allowCustomAttributes)
|
2018-09-17 02:54:42 +02:00
|
|
|
|
}
|
2020-01-06 21:58:51 +01:00
|
|
|
|
for folder in folders!.sorted() {
|
2020-01-14 05:20:20 +01:00
|
|
|
|
s += folder.OPMLString(indentLevel: indentLevel + 1, allowCustomAttributes: allowCustomAttributes)
|
2017-09-17 00:25:38 +02:00
|
|
|
|
}
|
|
|
|
|
return s
|
|
|
|
|
}
|
|
|
|
|
}
|