Merge branch 'master' into accent-color-experimental
This commit is contained in:
commit
3459f23461
|
@ -17,7 +17,7 @@ jobs:
|
|||
matrix:
|
||||
run-config:
|
||||
- { scheme: 'NetNewsWire', destination: 'platform=macOS'}
|
||||
- { scheme: 'NetNewsWire-iOS', destination: 'platform=iOS Simulator,OS=13.0,name=iPhone 11' }
|
||||
- { scheme: 'NetNewsWire-iOS', destination: 'platform=iOS Simulator,OS=13.4,name=iPhone 11' }
|
||||
|
||||
steps:
|
||||
- name: Checkout Project
|
||||
|
@ -25,8 +25,11 @@ jobs:
|
|||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: List Available Applications
|
||||
run: ls /Applications
|
||||
|
||||
- name: Switch to Xcode 11
|
||||
run: sudo xcode-select -s /Applications/Xcode_11.app
|
||||
run: sudo xcode-select -s /Applications/Xcode_11.4.app
|
||||
|
||||
- name: Show Build Version
|
||||
run: xcodebuild -version
|
||||
|
|
|
@ -27,7 +27,6 @@ public extension Notification.Name {
|
|||
static let AccountRefreshDidBegin = Notification.Name(rawValue: "AccountRefreshDidBegin")
|
||||
static let AccountRefreshDidFinish = Notification.Name(rawValue: "AccountRefreshDidFinish")
|
||||
static let AccountRefreshProgressDidChange = Notification.Name(rawValue: "AccountRefreshProgressDidChange")
|
||||
static let DownloadArticlesDidUpdateUnreadCounts = Notification.Name(rawValue: "DownloadArticlesDidUpdateUnreadCounts")
|
||||
static let AccountDidDownloadArticles = Notification.Name(rawValue: "AccountDidDownloadArticles")
|
||||
static let AccountStateDidChange = Notification.Name(rawValue: "AccountStateDidChange")
|
||||
static let StatusesDidChange = Notification.Name(rawValue: "StatusesDidChange")
|
||||
|
@ -136,6 +135,15 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container,
|
|||
public var topLevelWebFeeds = Set<WebFeed>()
|
||||
public var folders: Set<Folder>? = Set<Folder>()
|
||||
|
||||
public var externalID: String? {
|
||||
get {
|
||||
return metadata.externalID
|
||||
}
|
||||
set {
|
||||
metadata.externalID = newValue
|
||||
}
|
||||
}
|
||||
|
||||
public var sortedFolders: [Folder]? {
|
||||
if let folders = folders {
|
||||
return Array(folders).sorted(by: { $0.nameForDisplay < $1.nameForDisplay })
|
||||
|
@ -143,14 +151,25 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container,
|
|||
return nil
|
||||
}
|
||||
|
||||
private var webFeedDictionaryNeedsUpdate = true
|
||||
private var webFeedDictionariesNeedUpdate = true
|
||||
private var _idToWebFeedDictionary = [String: WebFeed]()
|
||||
var idToWebFeedDictionary: [String: WebFeed] {
|
||||
if webFeedDictionaryNeedsUpdate {
|
||||
if webFeedDictionariesNeedUpdate {
|
||||
rebuildWebFeedDictionaries()
|
||||
}
|
||||
return _idToWebFeedDictionary
|
||||
}
|
||||
private var _externalIDToWebFeedDictionary = [String: WebFeed]()
|
||||
var externalIDToWebFeedDictionary: [String: WebFeed] {
|
||||
if webFeedDictionariesNeedUpdate {
|
||||
rebuildWebFeedDictionaries()
|
||||
}
|
||||
return _externalIDToWebFeedDictionary
|
||||
}
|
||||
|
||||
var flattenedWebFeedURLs: Set<String> {
|
||||
return Set(flattenedWebFeeds().map({ $0.url }))
|
||||
}
|
||||
|
||||
var username: String? {
|
||||
get {
|
||||
|
@ -254,7 +273,8 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container,
|
|||
self.dataFolder = dataFolder
|
||||
|
||||
let databaseFilePath = (dataFolder as NSString).appendingPathComponent("DB.sqlite3")
|
||||
self.database = ArticlesDatabase(databaseFilePath: databaseFilePath, accountID: accountID)
|
||||
let retentionStyle: ArticlesDatabase.RetentionStyle = (type == .onMyMac || type == .cloudKit) ? .feedBased : .syncSystem
|
||||
self.database = ArticlesDatabase(databaseFilePath: databaseFilePath, accountID: accountID, retentionStyle: retentionStyle)
|
||||
|
||||
switch type {
|
||||
case .onMyMac:
|
||||
|
@ -370,8 +390,12 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container,
|
|||
grantingType.requestOAuthAccessToken(with: response, transport: transport, completion: completion)
|
||||
}
|
||||
|
||||
public func receiveRemoteNotification(userInfo: [AnyHashable : Any], completion: @escaping () -> Void) {
|
||||
delegate.receiveRemoteNotification(for: self, userInfo: userInfo, completion: completion)
|
||||
}
|
||||
|
||||
public func refreshAll(completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
self.delegate.refreshAll(for: self, completion: completion)
|
||||
delegate.refreshAll(for: self, completion: completion)
|
||||
}
|
||||
|
||||
public func syncArticleStatus(completion: ((Result<Void, Error>) -> Void)? = nil) {
|
||||
|
@ -417,14 +441,18 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container,
|
|||
}
|
||||
|
||||
public func suspendDatabase() {
|
||||
#if os(iOS)
|
||||
database.cancelAndSuspend()
|
||||
#endif
|
||||
save()
|
||||
}
|
||||
|
||||
/// Re-open the SQLite database and allow database calls.
|
||||
/// Call this *before* calling resume.
|
||||
public func resumeDatabaseAndDelegate() {
|
||||
#if os(iOS)
|
||||
database.resume()
|
||||
#endif
|
||||
delegate.resume()
|
||||
}
|
||||
|
||||
|
@ -443,49 +471,51 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container,
|
|||
delegate.accountWillBeDeleted(self)
|
||||
}
|
||||
|
||||
func loadOPMLItems(_ items: [RSOPMLItem], parentFolder: Folder?) {
|
||||
var feedsToAdd = Set<WebFeed>()
|
||||
|
||||
items.forEach { (item) in
|
||||
|
||||
func addOPMLItems(_ items: [RSOPMLItem]) {
|
||||
for item in items {
|
||||
if let feedSpecifier = item.feedSpecifier {
|
||||
let feed = newWebFeed(with: feedSpecifier)
|
||||
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) {
|
||||
folder.externalID = item.attributes?["nnw_externalID"] as? String
|
||||
if let itemChildren = item.children {
|
||||
loadOPMLItems(itemChildren, parentFolder: folder)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let parentFolder = parentFolder {
|
||||
for feed in feedsToAdd {
|
||||
parentFolder.addWebFeed(feed)
|
||||
}
|
||||
addWebFeed(newWebFeed(with: feedSpecifier))
|
||||
} else {
|
||||
for feed in feedsToAdd {
|
||||
addWebFeed(feed)
|
||||
if let title = item.titleFromAttributes, let folder = ensureFolder(with: title) {
|
||||
folder.externalID = item.attributes?["nnw_externalID"] as? String
|
||||
item.children?.forEach { itemChild in
|
||||
if let feedSpecifier = itemChild.feedSpecifier {
|
||||
folder.addWebFeed(newWebFeed(with: feedSpecifier))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func loadOPMLItems(_ items: [RSOPMLItem]) {
|
||||
addOPMLItems(OPMLNormalizer.normalize(items))
|
||||
}
|
||||
|
||||
public func markArticles(_ articles: Set<Article>, statusKey: ArticleStatus.Key, flag: Bool) -> Set<Article>? {
|
||||
return delegate.markArticles(for: self, articles: articles, statusKey: statusKey, flag: flag)
|
||||
}
|
||||
|
||||
func existingContainer(withExternalID externalID: String) -> Container? {
|
||||
guard self.externalID != externalID else {
|
||||
return self
|
||||
}
|
||||
return existingFolder(withExternalID: externalID)
|
||||
}
|
||||
|
||||
func existingContainers(withWebFeed webFeed: WebFeed) -> [Container] {
|
||||
var containers = [Container]()
|
||||
if topLevelWebFeeds.contains(webFeed) {
|
||||
containers.append(self)
|
||||
}
|
||||
folders?.forEach { folder in
|
||||
if folder.topLevelWebFeeds.contains(webFeed) {
|
||||
containers.append(folder)
|
||||
}
|
||||
}
|
||||
return containers
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func ensureFolder(with name: String) -> Folder? {
|
||||
// TODO: support subfolders, maybe, some day
|
||||
|
@ -516,10 +546,14 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container,
|
|||
return ensureFolder(with: folderName)
|
||||
}
|
||||
|
||||
public func findFolder(withDisplayName displayName: String) -> Folder? {
|
||||
public func existingFolder(withDisplayName displayName: String) -> Folder? {
|
||||
return folders?.first(where: { $0.nameForDisplay == displayName })
|
||||
}
|
||||
|
||||
public func existingFolder(withExternalID externalID: String) -> Folder? {
|
||||
return folders?.first(where: { $0.externalID == externalID })
|
||||
}
|
||||
|
||||
func newWebFeed(with opmlFeedSpecifier: RSOPMLFeedSpecifier) -> WebFeed {
|
||||
let feedURL = opmlFeedSpecifier.feedURL
|
||||
let metadata = webFeedMetadata(feedURL: feedURL, webFeedID: feedURL)
|
||||
|
@ -545,7 +579,6 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container,
|
|||
let feed = WebFeed(account: self, url: url, metadata: metadata)
|
||||
feed.name = name
|
||||
feed.homePageURL = homePageURL
|
||||
|
||||
return feed
|
||||
}
|
||||
|
||||
|
@ -566,7 +599,7 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container,
|
|||
}
|
||||
|
||||
public func addFolder(_ name: String, completion: @escaping (Result<Folder, Error>) -> Void) {
|
||||
delegate.addFolder(for: self, name: name, completion: completion)
|
||||
delegate.createFolder(for: self, name: name, completion: completion)
|
||||
}
|
||||
|
||||
public func removeFolder(_ folder: Folder, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
|
@ -679,60 +712,53 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container,
|
|||
// Or feeds inside folders were added or deleted.
|
||||
opmlFile.markAsDirty()
|
||||
flattenedWebFeedsNeedUpdate = true
|
||||
webFeedDictionaryNeedsUpdate = true
|
||||
webFeedDictionariesNeedUpdate = true
|
||||
}
|
||||
|
||||
func update(_ webFeed: WebFeed, with parsedFeed: ParsedFeed, _ completion: @escaping DatabaseCompletionBlock) {
|
||||
// Used only by an On My Mac account.
|
||||
// Used only by an On My Mac or iCloud account.
|
||||
precondition(Thread.isMainThread)
|
||||
precondition(type == .onMyMac || type == .cloudKit)
|
||||
|
||||
webFeed.takeSettings(from: parsedFeed)
|
||||
let webFeedIDsAndItems = [webFeed.webFeedID: parsedFeed.items]
|
||||
update(webFeedIDsAndItems: webFeedIDsAndItems, defaultRead: false, completion: completion)
|
||||
let parsedItems = parsedFeed.items
|
||||
guard !parsedItems.isEmpty else {
|
||||
completion(nil)
|
||||
return
|
||||
}
|
||||
|
||||
update(webFeed.webFeedID, with: parsedItems, completion: completion)
|
||||
}
|
||||
|
||||
func update(_ webFeedID: String, with parsedItems: Set<ParsedItem>, completion: @escaping DatabaseCompletionBlock) {
|
||||
// Used only by an On My Mac or iCloud account.
|
||||
precondition(Thread.isMainThread)
|
||||
precondition(type == .onMyMac || type == .cloudKit)
|
||||
|
||||
database.update(with: parsedItems, webFeedID: webFeedID) { updateArticlesResult in
|
||||
switch updateArticlesResult {
|
||||
case .success(let newAndUpdatedArticles):
|
||||
self.sendNotificationAbout(newAndUpdatedArticles)
|
||||
completion(nil)
|
||||
case .failure(let databaseError):
|
||||
completion(databaseError)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func update(webFeedIDsAndItems: [String: Set<ParsedItem>], defaultRead: Bool, completion: @escaping DatabaseCompletionBlock) {
|
||||
// Used only by syncing systems.
|
||||
precondition(Thread.isMainThread)
|
||||
precondition(type != .onMyMac && type != .cloudKit)
|
||||
guard !webFeedIDsAndItems.isEmpty else {
|
||||
completion(nil)
|
||||
return
|
||||
}
|
||||
|
||||
database.update(webFeedIDsAndItems: webFeedIDsAndItems, defaultRead: defaultRead) { updateArticlesResult in
|
||||
|
||||
func sendNotificationAbout(newArticles: Set<Article>?, updatedArticles: Set<Article>?) {
|
||||
var webFeeds = Set<WebFeed>()
|
||||
|
||||
if let newArticles = newArticles {
|
||||
webFeeds.formUnion(Set(newArticles.compactMap { $0.webFeed }))
|
||||
}
|
||||
if let updatedArticles = updatedArticles {
|
||||
webFeeds.formUnion(Set(updatedArticles.compactMap { $0.webFeed }))
|
||||
}
|
||||
|
||||
var shouldSendNotification = false
|
||||
var userInfo = [String: Any]()
|
||||
|
||||
if let newArticles = newArticles, !newArticles.isEmpty {
|
||||
shouldSendNotification = true
|
||||
userInfo[UserInfoKey.newArticles] = newArticles
|
||||
self.updateUnreadCounts(for: webFeeds) {
|
||||
NotificationCenter.default.post(name: .DownloadArticlesDidUpdateUnreadCounts, object: self, userInfo: nil)
|
||||
}
|
||||
}
|
||||
|
||||
if let updatedArticles = updatedArticles, !updatedArticles.isEmpty {
|
||||
shouldSendNotification = true
|
||||
userInfo[UserInfoKey.updatedArticles] = updatedArticles
|
||||
}
|
||||
|
||||
if shouldSendNotification {
|
||||
userInfo[UserInfoKey.webFeeds] = webFeeds
|
||||
NotificationCenter.default.post(name: .AccountDidDownloadArticles, object: self, userInfo: userInfo)
|
||||
}
|
||||
}
|
||||
|
||||
switch updateArticlesResult {
|
||||
case .success(let newAndUpdatedArticles):
|
||||
sendNotificationAbout(newArticles: newAndUpdatedArticles.newArticles, updatedArticles: newAndUpdatedArticles.updatedArticles)
|
||||
self.sendNotificationAbout(newAndUpdatedArticles)
|
||||
completion(nil)
|
||||
case .failure(let databaseError):
|
||||
completion(databaseError)
|
||||
|
@ -1150,13 +1176,18 @@ private extension Account {
|
|||
|
||||
func rebuildWebFeedDictionaries() {
|
||||
var idDictionary = [String: WebFeed]()
|
||||
var externalIDDictionary = [String: WebFeed]()
|
||||
|
||||
flattenedWebFeeds().forEach { (feed) in
|
||||
idDictionary[feed.webFeedID] = feed
|
||||
if let externalID = feed.externalID {
|
||||
externalIDDictionary[externalID] = feed
|
||||
}
|
||||
}
|
||||
|
||||
_idToWebFeedDictionary = idDictionary
|
||||
webFeedDictionaryNeedsUpdate = false
|
||||
_externalIDToWebFeedDictionary = externalIDDictionary
|
||||
webFeedDictionariesNeedUpdate = false
|
||||
}
|
||||
|
||||
func updateUnreadCount() {
|
||||
|
@ -1252,6 +1283,36 @@ private extension Account {
|
|||
feed.unreadCount = unreadCount
|
||||
}
|
||||
}
|
||||
|
||||
func sendNotificationAbout(_ newAndUpdatedArticles: NewAndUpdatedArticles) {
|
||||
var webFeeds = Set<WebFeed>()
|
||||
|
||||
if let newArticles = newAndUpdatedArticles.newArticles {
|
||||
webFeeds.formUnion(Set(newArticles.compactMap { $0.webFeed }))
|
||||
}
|
||||
if let updatedArticles = newAndUpdatedArticles.updatedArticles {
|
||||
webFeeds.formUnion(Set(updatedArticles.compactMap { $0.webFeed }))
|
||||
}
|
||||
|
||||
var shouldSendNotification = false
|
||||
var userInfo = [String: Any]()
|
||||
|
||||
if let newArticles = newAndUpdatedArticles.newArticles, !newArticles.isEmpty {
|
||||
shouldSendNotification = true
|
||||
userInfo[UserInfoKey.newArticles] = newArticles
|
||||
self.updateUnreadCounts(for: webFeeds)
|
||||
}
|
||||
|
||||
if let updatedArticles = newAndUpdatedArticles.updatedArticles, !updatedArticles.isEmpty {
|
||||
shouldSendNotification = true
|
||||
userInfo[UserInfoKey.updatedArticles] = updatedArticles
|
||||
}
|
||||
|
||||
if shouldSendNotification {
|
||||
userInfo[UserInfoKey.webFeeds] = webFeeds
|
||||
NotificationCenter.default.post(name: .AccountDidDownloadArticles, object: self, userInfo: userInfo)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Container Overrides
|
||||
|
@ -1261,6 +1322,11 @@ extension Account {
|
|||
public func existingWebFeed(withWebFeedID webFeedID: String) -> WebFeed? {
|
||||
return idToWebFeedDictionary[webFeedID]
|
||||
}
|
||||
|
||||
public func existingWebFeed(withExternalID externalID: String) -> WebFeed? {
|
||||
return externalIDToWebFeedDictionary[externalID]
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - OPMLRepresentable
|
||||
|
|
|
@ -34,12 +34,15 @@
|
|||
510BD111232C3801002692E4 /* AccountMetadataFile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 510BD110232C3801002692E4 /* AccountMetadataFile.swift */; };
|
||||
510BD113232C3E9D002692E4 /* WebFeedMetadataFile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 510BD112232C3E9D002692E4 /* WebFeedMetadataFile.swift */; };
|
||||
511B9804237CD4270028BCAA /* FeedIdentifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 511B9803237CD4270028BCAA /* FeedIdentifier.swift */; };
|
||||
512DD4CB2431000600C17B1F /* CKRecord+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 512DD4CA2431000600C17B1F /* CKRecord+Extensions.swift */; };
|
||||
512DD4CD2431098700C17B1F /* CloudKitAccountZoneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 512DD4CC2431098700C17B1F /* CloudKitAccountZoneDelegate.swift */; };
|
||||
513323082281070D00C30F19 /* AccountFeedbinSyncTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 513323072281070C00C30F19 /* AccountFeedbinSyncTest.swift */; };
|
||||
5133230A2281082F00C30F19 /* subscriptions_initial.json in Resources */ = {isa = PBXBuildFile; fileRef = 513323092281082F00C30F19 /* subscriptions_initial.json */; };
|
||||
5133230C2281088A00C30F19 /* subscriptions_add.json in Resources */ = {isa = PBXBuildFile; fileRef = 5133230B2281088A00C30F19 /* subscriptions_add.json */; };
|
||||
5144EA49227B497600D19003 /* FeedbinAPICaller.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5144EA48227B497600D19003 /* FeedbinAPICaller.swift */; };
|
||||
5144EA4E227B829A00D19003 /* FeedbinAccountDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5144EA4D227B829A00D19003 /* FeedbinAccountDelegate.swift */; };
|
||||
514BF5202391B0DB00902FE8 /* SingleArticleFetcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 514BF51F2391B0DB00902FE8 /* SingleArticleFetcher.swift */; };
|
||||
5150FFFE243823B800C1A442 /* CloudKitError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5150FFFD243823B800C1A442 /* CloudKitError.swift */; };
|
||||
5154367B228EEB28005E1CDF /* FeedbinImportResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5154367A228EEB28005E1CDF /* FeedbinImportResult.swift */; };
|
||||
515E4EB52324FF8C0057B0E7 /* CredentialsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 515E4EB22324FF8C0057B0E7 /* CredentialsManager.swift */; };
|
||||
515E4EB62324FF8C0057B0E7 /* URLRequest+RSWeb.swift in Sources */ = {isa = PBXBuildFile; fileRef = 515E4EB32324FF8C0057B0E7 /* URLRequest+RSWeb.swift */; };
|
||||
|
@ -53,11 +56,13 @@
|
|||
5165D72A22835F7D00D9D53D /* HTMLFeedFinder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5165D71E22835E9800D9D53D /* HTMLFeedFinder.swift */; };
|
||||
5165D73122837F3400D9D53D /* InitialFeedDownloader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5165D73022837F3400D9D53D /* InitialFeedDownloader.swift */; };
|
||||
5170743C232AEDB500A461A3 /* OPMLFile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5170743B232AEDB500A461A3 /* OPMLFile.swift */; };
|
||||
519E84A62433D49000D238B0 /* OPMLNormalizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 519E84A52433D49000D238B0 /* OPMLNormalizer.swift */; };
|
||||
519E84A82434C5EF00D238B0 /* CloudKitArticlesZone.swift in Sources */ = {isa = PBXBuildFile; fileRef = 519E84A72434C5EF00D238B0 /* CloudKitArticlesZone.swift */; };
|
||||
519E84AC2435019100D238B0 /* CloudKitArticlesZoneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 519E84AB2435019100D238B0 /* CloudKitArticlesZoneDelegate.swift */; };
|
||||
51BB7B84233531BC008E8144 /* AccountBehaviors.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51BB7B83233531BC008E8144 /* AccountBehaviors.swift */; };
|
||||
51BC8FCC237EC055004F8B56 /* Feed.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51BC8FCB237EC055004F8B56 /* Feed.swift */; };
|
||||
51BFDECE238B508D00216323 /* ContainerIdentifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51BFDECD238B508D00216323 /* ContainerIdentifier.swift */; };
|
||||
51C034DF242D65D20014DC71 /* CloudKitResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51C034DE242D65D20014DC71 /* CloudKitResult.swift */; };
|
||||
51C034E1242D660D0014DC71 /* CKError+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51C034E0242D660D0014DC71 /* CKError+Extensions.swift */; };
|
||||
51C034DF242D65D20014DC71 /* CloudKitZoneResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51C034DE242D65D20014DC71 /* CloudKitZoneResult.swift */; };
|
||||
51D58755227F53BE00900287 /* FeedbinTag.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51D58754227F53BE00900287 /* FeedbinTag.swift */; };
|
||||
51D5875A227F630B00900287 /* tags_delete.json in Resources */ = {isa = PBXBuildFile; fileRef = 51D58757227F630B00900287 /* tags_delete.json */; };
|
||||
51D5875B227F630B00900287 /* tags_add.json in Resources */ = {isa = PBXBuildFile; fileRef = 51D58758227F630B00900287 /* tags_add.json */; };
|
||||
|
@ -68,9 +73,6 @@
|
|||
51E490362288C37100C791F0 /* FeedbinDate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51E490352288C37100C791F0 /* FeedbinDate.swift */; };
|
||||
51E4DB2E242633ED0091EB5B /* CloudKitZone.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51E4DB2D242633ED0091EB5B /* CloudKitZone.swift */; };
|
||||
51E4DB302426353D0091EB5B /* CloudKitAccountZone.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51E4DB2F2426353D0091EB5B /* CloudKitAccountZone.swift */; };
|
||||
51E4DB3224264B470091EB5B /* WebFeed+CloudKit.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51E4DB3124264B470091EB5B /* WebFeed+CloudKit.swift */; };
|
||||
51E4DB3424264CD50091EB5B /* Folder+CloudKit.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51E4DB3324264CD50091EB5B /* Folder+CloudKit.swift */; };
|
||||
51E4DB362426693F0091EB5B /* CloudKitRecordConvertable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51E4DB352426693F0091EB5B /* CloudKitRecordConvertable.swift */; };
|
||||
51E59599228C77BC00FCC42B /* FeedbinUnreadEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51E59598228C77BC00FCC42B /* FeedbinUnreadEntry.swift */; };
|
||||
51E5959B228C781500FCC42B /* FeedbinStarredEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51E5959A228C781500FCC42B /* FeedbinStarredEntry.swift */; };
|
||||
552032F8229D5D5A009559E0 /* ReaderAPIEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 552032ED229D5D5A009559E0 /* ReaderAPIEntry.swift */; };
|
||||
|
@ -267,12 +269,15 @@
|
|||
510BD110232C3801002692E4 /* AccountMetadataFile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountMetadataFile.swift; sourceTree = "<group>"; };
|
||||
510BD112232C3E9D002692E4 /* WebFeedMetadataFile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebFeedMetadataFile.swift; sourceTree = "<group>"; };
|
||||
511B9803237CD4270028BCAA /* FeedIdentifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedIdentifier.swift; sourceTree = "<group>"; };
|
||||
512DD4CA2431000600C17B1F /* CKRecord+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CKRecord+Extensions.swift"; sourceTree = "<group>"; };
|
||||
512DD4CC2431098700C17B1F /* CloudKitAccountZoneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudKitAccountZoneDelegate.swift; sourceTree = "<group>"; };
|
||||
513323072281070C00C30F19 /* AccountFeedbinSyncTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountFeedbinSyncTest.swift; sourceTree = "<group>"; };
|
||||
513323092281082F00C30F19 /* subscriptions_initial.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = subscriptions_initial.json; sourceTree = "<group>"; };
|
||||
5133230B2281088A00C30F19 /* subscriptions_add.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = subscriptions_add.json; sourceTree = "<group>"; };
|
||||
5144EA48227B497600D19003 /* FeedbinAPICaller.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedbinAPICaller.swift; sourceTree = "<group>"; };
|
||||
5144EA4D227B829A00D19003 /* FeedbinAccountDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedbinAccountDelegate.swift; sourceTree = "<group>"; };
|
||||
514BF51F2391B0DB00902FE8 /* SingleArticleFetcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SingleArticleFetcher.swift; sourceTree = "<group>"; };
|
||||
5150FFFD243823B800C1A442 /* CloudKitError.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CloudKitError.swift; sourceTree = "<group>"; };
|
||||
5154367A228EEB28005E1CDF /* FeedbinImportResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedbinImportResult.swift; sourceTree = "<group>"; };
|
||||
515E4EB22324FF8C0057B0E7 /* CredentialsManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CredentialsManager.swift; sourceTree = "<group>"; };
|
||||
515E4EB32324FF8C0057B0E7 /* URLRequest+RSWeb.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "URLRequest+RSWeb.swift"; sourceTree = "<group>"; };
|
||||
|
@ -287,11 +292,13 @@
|
|||
5165D73022837F3400D9D53D /* InitialFeedDownloader.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = InitialFeedDownloader.swift; sourceTree = "<group>"; };
|
||||
5170743B232AEDB500A461A3 /* OPMLFile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OPMLFile.swift; sourceTree = "<group>"; };
|
||||
518B2EA52351306200400001 /* Account_project_test.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Account_project_test.xcconfig; sourceTree = "<group>"; };
|
||||
519E84A52433D49000D238B0 /* OPMLNormalizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OPMLNormalizer.swift; sourceTree = "<group>"; };
|
||||
519E84A72434C5EF00D238B0 /* CloudKitArticlesZone.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudKitArticlesZone.swift; sourceTree = "<group>"; };
|
||||
519E84AB2435019100D238B0 /* CloudKitArticlesZoneDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CloudKitArticlesZoneDelegate.swift; sourceTree = "<group>"; };
|
||||
51BB7B83233531BC008E8144 /* AccountBehaviors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountBehaviors.swift; sourceTree = "<group>"; };
|
||||
51BC8FCB237EC055004F8B56 /* Feed.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Feed.swift; sourceTree = "<group>"; };
|
||||
51BFDECD238B508D00216323 /* ContainerIdentifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContainerIdentifier.swift; sourceTree = "<group>"; };
|
||||
51C034DE242D65D20014DC71 /* CloudKitResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudKitResult.swift; sourceTree = "<group>"; };
|
||||
51C034E0242D660D0014DC71 /* CKError+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CKError+Extensions.swift"; sourceTree = "<group>"; };
|
||||
51C034DE242D65D20014DC71 /* CloudKitZoneResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudKitZoneResult.swift; sourceTree = "<group>"; };
|
||||
51D58754227F53BE00900287 /* FeedbinTag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedbinTag.swift; sourceTree = "<group>"; };
|
||||
51D58757227F630B00900287 /* tags_delete.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = tags_delete.json; sourceTree = "<group>"; };
|
||||
51D58758227F630B00900287 /* tags_add.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = tags_add.json; sourceTree = "<group>"; };
|
||||
|
@ -302,9 +309,6 @@
|
|||
51E490352288C37100C791F0 /* FeedbinDate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedbinDate.swift; sourceTree = "<group>"; };
|
||||
51E4DB2D242633ED0091EB5B /* CloudKitZone.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudKitZone.swift; sourceTree = "<group>"; };
|
||||
51E4DB2F2426353D0091EB5B /* CloudKitAccountZone.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudKitAccountZone.swift; sourceTree = "<group>"; };
|
||||
51E4DB3124264B470091EB5B /* WebFeed+CloudKit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WebFeed+CloudKit.swift"; sourceTree = "<group>"; };
|
||||
51E4DB3324264CD50091EB5B /* Folder+CloudKit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Folder+CloudKit.swift"; sourceTree = "<group>"; };
|
||||
51E4DB352426693F0091EB5B /* CloudKitRecordConvertable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudKitRecordConvertable.swift; sourceTree = "<group>"; };
|
||||
51E59598228C77BC00FCC42B /* FeedbinUnreadEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedbinUnreadEntry.swift; sourceTree = "<group>"; };
|
||||
51E5959A228C781500FCC42B /* FeedbinStarredEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedbinStarredEntry.swift; sourceTree = "<group>"; };
|
||||
552032ED229D5D5A009559E0 /* ReaderAPIEntry.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReaderAPIEntry.swift; sourceTree = "<group>"; };
|
||||
|
@ -517,14 +521,15 @@
|
|||
5103A9D7242253DC00410853 /* CloudKit */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
51C034E0242D660D0014DC71 /* CKError+Extensions.swift */,
|
||||
512DD4CA2431000600C17B1F /* CKRecord+Extensions.swift */,
|
||||
5103A9D82422546800410853 /* CloudKitAccountDelegate.swift */,
|
||||
51E4DB2F2426353D0091EB5B /* CloudKitAccountZone.swift */,
|
||||
51E4DB352426693F0091EB5B /* CloudKitRecordConvertable.swift */,
|
||||
51C034DE242D65D20014DC71 /* CloudKitResult.swift */,
|
||||
512DD4CC2431098700C17B1F /* CloudKitAccountZoneDelegate.swift */,
|
||||
519E84A72434C5EF00D238B0 /* CloudKitArticlesZone.swift */,
|
||||
519E84AB2435019100D238B0 /* CloudKitArticlesZoneDelegate.swift */,
|
||||
5150FFFD243823B800C1A442 /* CloudKitError.swift */,
|
||||
51E4DB2D242633ED0091EB5B /* CloudKitZone.swift */,
|
||||
51E4DB3324264CD50091EB5B /* Folder+CloudKit.swift */,
|
||||
51E4DB3124264B470091EB5B /* WebFeed+CloudKit.swift */,
|
||||
51C034DE242D65D20014DC71 /* CloudKitZoneResult.swift */,
|
||||
);
|
||||
path = CloudKit;
|
||||
sourceTree = "<group>";
|
||||
|
@ -673,6 +678,7 @@
|
|||
51E3EB40229AF61B00645299 /* AccountError.swift */,
|
||||
846E77531F6F00E300A165E2 /* AccountManager.swift */,
|
||||
5170743B232AEDB500A461A3 /* OPMLFile.swift */,
|
||||
519E84A52433D49000D238B0 /* OPMLNormalizer.swift */,
|
||||
84AF4EA3222CFDD100F6A800 /* AccountMetadata.swift */,
|
||||
510BD110232C3801002692E4 /* AccountMetadataFile.swift */,
|
||||
84F73CF0202788D80000BCEF /* ArticleFetcher.swift */,
|
||||
|
@ -1085,12 +1091,15 @@
|
|||
9EA643D5239306AC0018A28C /* FeedlyFeedsSearchResponse.swift in Sources */,
|
||||
9EAEC60E2332FEC20085D7C9 /* FeedlyFeed.swift in Sources */,
|
||||
5144EA4E227B829A00D19003 /* FeedbinAccountDelegate.swift in Sources */,
|
||||
519E84AC2435019100D238B0 /* CloudKitArticlesZoneDelegate.swift in Sources */,
|
||||
512DD4CB2431000600C17B1F /* CKRecord+Extensions.swift in Sources */,
|
||||
3B826DAF2385C81C00FC1ADB /* FeedWranglerGenericResult.swift in Sources */,
|
||||
9ECC9A85234DC16E009B5144 /* FeedlyAccountDelegateError.swift in Sources */,
|
||||
9EA3133B231E368100268BA0 /* FeedlyAccountDelegate.swift in Sources */,
|
||||
51E5959B228C781500FCC42B /* FeedbinStarredEntry.swift in Sources */,
|
||||
846E77451F6EF9B900A165E2 /* Container.swift in Sources */,
|
||||
9EA643D3239305680018A28C /* FeedlySearchOperation.swift in Sources */,
|
||||
5150FFFE243823B800C1A442 /* CloudKitError.swift in Sources */,
|
||||
9E5EC15D23E0D58500A4E503 /* FeedlyFeedParser.swift in Sources */,
|
||||
9E1D15532334304B00F4944C /* FeedlyGetStreamContentsOperation.swift in Sources */,
|
||||
9E12B0202334696A00ADE5A0 /* FeedlyCreateFeedsForCollectionFoldersOperation.swift in Sources */,
|
||||
|
@ -1104,6 +1113,7 @@
|
|||
5103A9D92422546800410853 /* CloudKitAccountDelegate.swift in Sources */,
|
||||
5165D73122837F3400D9D53D /* InitialFeedDownloader.swift in Sources */,
|
||||
9E784EBE237E890600099B1B /* FeedlyLogoutOperation.swift in Sources */,
|
||||
519E84A62433D49000D238B0 /* OPMLNormalizer.swift in Sources */,
|
||||
9EEEF71F23545CB4009E9D80 /* FeedlySendArticleStatusesOperation.swift in Sources */,
|
||||
9EBD49C223C67784005AD5CD /* FeedlyEntryIdentifierProviding.swift in Sources */,
|
||||
846E77541F6F00E300A165E2 /* AccountManager.swift in Sources */,
|
||||
|
@ -1111,11 +1121,9 @@
|
|||
51E490362288C37100C791F0 /* FeedbinDate.swift in Sources */,
|
||||
9EEAE06E235D002D00E3FEE4 /* FeedlyGetCollectionsService.swift in Sources */,
|
||||
5165D72922835F7A00D9D53D /* FeedSpecifier.swift in Sources */,
|
||||
51E4DB3424264CD50091EB5B /* Folder+CloudKit.swift in Sources */,
|
||||
9E85C8ED2367020700D0F1F7 /* FeedlyGetEntriesService.swift in Sources */,
|
||||
9E5EC15923E01D8A00A4E503 /* FeedlyCollectionParser.swift in Sources */,
|
||||
9E84DC492359A73600D6E809 /* FeedlyCheckpointOperation.swift in Sources */,
|
||||
51E4DB3224264B470091EB5B /* WebFeed+CloudKit.swift in Sources */,
|
||||
9E85C8EB236700E600D0F1F7 /* FeedlyGetEntriesOperation.swift in Sources */,
|
||||
9E1D154D233370D800F4944C /* FeedlySyncAllOperation.swift in Sources */,
|
||||
9E44C90F23C6FF3600CCC286 /* FeedlyIngestStreamArticleIdsOperation.swift in Sources */,
|
||||
|
@ -1156,7 +1164,6 @@
|
|||
51E59599228C77BC00FCC42B /* FeedbinUnreadEntry.swift in Sources */,
|
||||
552032F8229D5D5A009559E0 /* ReaderAPIEntry.swift in Sources */,
|
||||
552032FB229D5D5A009559E0 /* ReaderAPITag.swift in Sources */,
|
||||
51E4DB362426693F0091EB5B /* CloudKitRecordConvertable.swift in Sources */,
|
||||
5165D72822835F7800D9D53D /* FeedFinder.swift in Sources */,
|
||||
9EBD49C023C67602005AD5CD /* FeedlyDownloadArticlesOperation.swift in Sources */,
|
||||
51D58755227F53BE00900287 /* FeedbinTag.swift in Sources */,
|
||||
|
@ -1166,6 +1173,7 @@
|
|||
51BB7B84233531BC008E8144 /* AccountBehaviors.swift in Sources */,
|
||||
9E1773D923458D590056A5A8 /* FeedlyResourceId.swift in Sources */,
|
||||
9EE4CCFA234F106600FBAE4B /* FeedlyFeedContainerValidator.swift in Sources */,
|
||||
519E84A82434C5EF00D238B0 /* CloudKitArticlesZone.swift in Sources */,
|
||||
552032FC229D5D5A009559E0 /* ReaderAPIUnreadEntry.swift in Sources */,
|
||||
9EC688EA232B973C00A8D0A2 /* FeedlyAPICaller.swift in Sources */,
|
||||
9E1773D32345700F0056A5A8 /* FeedlyLink.swift in Sources */,
|
||||
|
@ -1174,7 +1182,6 @@
|
|||
9E964EB823754AC400A7AF2E /* OAuthAuthorizationClient+Feedly.swift in Sources */,
|
||||
3B3A33E7238D3D6800314204 /* Secrets.swift in Sources */,
|
||||
9EF1B10923590E93000A486A /* FeedlyStreamIds.swift in Sources */,
|
||||
51C034E1242D660D0014DC71 /* CKError+Extensions.swift in Sources */,
|
||||
84D09623217418DC00D77525 /* FeedbinTagging.swift in Sources */,
|
||||
84CAD7161FDF2E22000F0755 /* FeedbinEntry.swift in Sources */,
|
||||
5165D72A22835F7D00D9D53D /* HTMLFeedFinder.swift in Sources */,
|
||||
|
@ -1186,6 +1193,7 @@
|
|||
515E4EB52324FF8C0057B0E7 /* CredentialsManager.swift in Sources */,
|
||||
844B297F210CE37E004020B3 /* UnreadCountProvider.swift in Sources */,
|
||||
9E1773D5234570E30056A5A8 /* FeedlyEntryParser.swift in Sources */,
|
||||
512DD4CD2431098700C17B1F /* CloudKitAccountZoneDelegate.swift in Sources */,
|
||||
51BFDECE238B508D00216323 /* ContainerIdentifier.swift in Sources */,
|
||||
9E1D1555233431A600F4944C /* FeedlyOperation.swift in Sources */,
|
||||
84F1F06E2243524700DA0616 /* AccountMetadata.swift in Sources */,
|
||||
|
@ -1196,7 +1204,7 @@
|
|||
3B826DAC2385C81C00FC1ADB /* FeedWranglerAccountDelegate.swift in Sources */,
|
||||
769F295938E5A30D03DFF88F /* NewsBlurAccountDelegate.swift in Sources */,
|
||||
769F2BA02EF5F329CDE45F5A /* NewsBlurAPICaller.swift in Sources */,
|
||||
51C034DF242D65D20014DC71 /* CloudKitResult.swift in Sources */,
|
||||
51C034DF242D65D20014DC71 /* CloudKitZoneResult.swift in Sources */,
|
||||
179DB28CF49F73A945EBF5DB /* NewsBlurLoginResponse.swift in Sources */,
|
||||
179DBF4DE2562D4C532F6008 /* NewsBlurFeed.swift in Sources */,
|
||||
179DB02FFBC17AC9798F0EBC /* NewsBlurStory.swift in Sources */,
|
||||
|
|
|
@ -22,13 +22,15 @@ protocol AccountDelegate {
|
|||
|
||||
var refreshProgress: DownloadProgress { get }
|
||||
|
||||
func receiveRemoteNotification(for account: Account, userInfo: [AnyHashable : Any], completion: @escaping () -> Void)
|
||||
|
||||
func refreshAll(for account: Account, completion: @escaping (Result<Void, Error>) -> Void)
|
||||
func sendArticleStatus(for account: Account, completion: @escaping ((Result<Void, Error>) -> Void))
|
||||
func refreshArticleStatus(for account: Account, completion: @escaping ((Result<Void, Error>) -> Void))
|
||||
|
||||
func importOPML(for account:Account, opmlFile: URL, completion: @escaping (Result<Void, Error>) -> Void)
|
||||
|
||||
func addFolder(for account: Account, name: String, completion: @escaping (Result<Folder, Error>) -> Void)
|
||||
func createFolder(for account: Account, name: String, completion: @escaping (Result<Folder, Error>) -> Void)
|
||||
func renameFolder(for account: Account, with folder: Folder, to name: String, completion: @escaping (Result<Void, Error>) -> Void)
|
||||
func removeFolder(for account: Account, with folder: Folder, completion: @escaping (Result<Void, Error>) -> Void)
|
||||
|
||||
|
|
|
@ -72,7 +72,7 @@ public final class AccountManager: UnreadCountProvider {
|
|||
return lastArticleFetchEndTime
|
||||
}
|
||||
|
||||
public func findActiveAccount(forDisplayName displayName: String) -> Account? {
|
||||
public func existingActiveAccount(forDisplayName displayName: String) -> Account? {
|
||||
return AccountManager.shared.activeAccounts.first(where: { $0.nameForDisplay == displayName })
|
||||
}
|
||||
|
||||
|
@ -184,6 +184,21 @@ public final class AccountManager: UnreadCountProvider {
|
|||
accounts.forEach { $0.resume() }
|
||||
}
|
||||
|
||||
public func receiveRemoteNotification(userInfo: [AnyHashable : Any], completion: (() -> Void)? = nil) {
|
||||
let group = DispatchGroup()
|
||||
|
||||
activeAccounts.forEach { account in
|
||||
group.enter()
|
||||
account.receiveRemoteNotification(userInfo: userInfo) {
|
||||
group.leave()
|
||||
}
|
||||
}
|
||||
|
||||
group.notify(queue: DispatchQueue.main) {
|
||||
completion?()
|
||||
}
|
||||
}
|
||||
|
||||
public func refreshAll(errorHandler: @escaping (Error) -> Void, completion: (() -> Void)? = nil) {
|
||||
let group = DispatchGroup()
|
||||
|
||||
|
@ -203,7 +218,6 @@ public final class AccountManager: UnreadCountProvider {
|
|||
group.notify(queue: DispatchQueue.main) {
|
||||
completion?()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public func syncArticleStatusAll(completion: (() -> Void)? = nil) {
|
||||
|
|
|
@ -23,6 +23,7 @@ final class AccountMetadata: Codable {
|
|||
case lastArticleFetchStartTime = "lastArticleFetch"
|
||||
case lastArticleFetchEndTime
|
||||
case endpointURL
|
||||
case externalID
|
||||
}
|
||||
|
||||
var name: String? {
|
||||
|
@ -81,6 +82,14 @@ final class AccountMetadata: Codable {
|
|||
}
|
||||
}
|
||||
|
||||
var externalID: String? {
|
||||
didSet {
|
||||
if externalID != oldValue {
|
||||
valueDidChange(.externalID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
weak var delegate: AccountMetadataDelegate?
|
||||
|
||||
func valueDidChange(_ key: CodingKeys) {
|
||||
|
|
|
@ -287,6 +287,7 @@ class FeedlyTestSupport {
|
|||
url: "http://localhost/",
|
||||
externalURL: "http://localhost/\(pair.0)/articles/\(index).html",
|
||||
title: "Title\(index)",
|
||||
language: nil,
|
||||
contentHTML: "Content \(index) HTML.",
|
||||
contentText: "Content \(index) Text",
|
||||
summary: nil,
|
||||
|
|
|
@ -0,0 +1,26 @@
|
|||
//
|
||||
// CKRecord+Extensions.swift
|
||||
// Account
|
||||
//
|
||||
// Created by Maurice Parker on 3/29/20.
|
||||
// Copyright © 2020 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import CloudKit
|
||||
|
||||
extension CKRecord {
|
||||
|
||||
var externalID: String {
|
||||
return recordID.externalID
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension CKRecord.ID {
|
||||
|
||||
var externalID: String {
|
||||
return recordName
|
||||
}
|
||||
|
||||
}
|
|
@ -30,7 +30,9 @@ final class CloudKitAccountDelegate: AccountDelegate {
|
|||
return CKContainer(identifier: "iCloud.\(orgID).NetNewsWire")
|
||||
}()
|
||||
|
||||
private lazy var zones: [CloudKitZone] = [accountZone, articlesZone]
|
||||
private let accountZone: CloudKitAccountZone
|
||||
private let articlesZone: CloudKitArticlesZone
|
||||
|
||||
private let refresher = LocalAccountRefresher()
|
||||
|
||||
|
@ -41,37 +43,106 @@ final class CloudKitAccountDelegate: AccountDelegate {
|
|||
var credentials: Credentials?
|
||||
var accountMetadata: AccountMetadata?
|
||||
|
||||
var refreshProgress: DownloadProgress {
|
||||
return refresher.progress
|
||||
}
|
||||
|
||||
// init() {
|
||||
// accountZone.startUp() { result in
|
||||
// if case .failure(let error) = result {
|
||||
// os_log(.error, log: self.log, "Account zone startup error: %@.", error.localizedDescription)
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
var refreshProgress = DownloadProgress(numberOfTasks: 0)
|
||||
|
||||
init(dataFolder: String) {
|
||||
accountZone = CloudKitAccountZone(container: container)
|
||||
articlesZone = CloudKitArticlesZone(container: container)
|
||||
|
||||
let databaseFilePath = (dataFolder as NSString).appendingPathComponent("Sync.sqlite3")
|
||||
database = SyncDatabase(databaseFilePath: databaseFilePath)
|
||||
}
|
||||
|
||||
func refreshAll(for account: Account, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
refresher.refreshFeeds(account.flattenedWebFeeds()) {
|
||||
account.metadata.lastArticleFetchEndTime = Date()
|
||||
completion(.success(()))
|
||||
func receiveRemoteNotification(for account: Account, userInfo: [AnyHashable : Any], completion: @escaping () -> Void) {
|
||||
os_log(.debug, log: log, "Processing remote notification...")
|
||||
|
||||
let group = DispatchGroup()
|
||||
|
||||
zones.forEach { zone in
|
||||
group.enter()
|
||||
zone.receiveRemoteNotification(userInfo: userInfo) {
|
||||
group.leave()
|
||||
}
|
||||
}
|
||||
|
||||
group.notify(queue: DispatchQueue.main) {
|
||||
os_log(.debug, log: self.log, "Done processing remote notification...")
|
||||
completion()
|
||||
}
|
||||
}
|
||||
|
||||
func refreshAll(for account: Account, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
guard refreshProgress.isComplete else {
|
||||
completion(.success(()))
|
||||
return
|
||||
}
|
||||
refreshAll(for: account, downloadFeeds: true, completion: completion)
|
||||
}
|
||||
|
||||
func sendArticleStatus(for account: Account, completion: @escaping ((Result<Void, Error>) -> Void)) {
|
||||
os_log(.debug, log: log, "Sending article statuses...")
|
||||
|
||||
database.selectForProcessing { result in
|
||||
|
||||
func processStatuses(_ syncStatuses: [SyncStatus]) {
|
||||
guard syncStatuses.count > 0 else {
|
||||
completion(.success(()))
|
||||
return
|
||||
}
|
||||
|
||||
func refreshArticleStatus(for account: Account, completion: @escaping ((Result<Void, Error>) -> Void)) {
|
||||
let starredArticleIDs = syncStatuses.filter({ $0.key == .starred && $0.flag == true }).map({ $0.articleID })
|
||||
account.fetchArticlesAsync(.articleIDs(Set(starredArticleIDs))) { result in
|
||||
|
||||
func processWithArticles(_ starredArticles: Set<Article>) {
|
||||
|
||||
self.articlesZone.sendArticleStatus(syncStatuses, starredArticles: starredArticles) { result in
|
||||
switch result {
|
||||
case .success:
|
||||
self.database.deleteSelectedForProcessing(syncStatuses.map({ $0.articleID }) )
|
||||
os_log(.debug, log: self.log, "Done sending article statuses.")
|
||||
completion(.success(()))
|
||||
case .failure(let error):
|
||||
self.database.resetSelectedForProcessing(syncStatuses.map({ $0.articleID }) )
|
||||
self.processAccountError(account, error)
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
switch result {
|
||||
case .success(let starredArticles):
|
||||
processWithArticles(starredArticles)
|
||||
case .failure(let databaseError):
|
||||
completion(.failure(databaseError))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
switch result {
|
||||
case .success(let syncStatuses):
|
||||
processStatuses(syncStatuses)
|
||||
case .failure(let databaseError):
|
||||
completion(.failure(databaseError))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func refreshArticleStatus(for account: Account, completion: @escaping ((Result<Void, Error>) -> Void)) {
|
||||
os_log(.debug, log: log, "Refreshing article statuses...")
|
||||
|
||||
articlesZone.refreshArticleStatus() { result in
|
||||
os_log(.debug, log: self.log, "Done refreshing article statuses.")
|
||||
switch result {
|
||||
case .success:
|
||||
completion(.success(()))
|
||||
case .failure(let error):
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func importOPML(for account:Account, opmlFile: URL, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
|
@ -104,60 +175,97 @@ final class CloudKitAccountDelegate: AccountDelegate {
|
|||
return
|
||||
}
|
||||
|
||||
guard let children = loadDocument.children else {
|
||||
guard let opmlItems = loadDocument.children, let rootExternalID = account.externalID else {
|
||||
return
|
||||
}
|
||||
|
||||
BatchUpdate.shared.perform {
|
||||
account.loadOPMLItems(children, parentFolder: nil)
|
||||
let normalizedItems = OPMLNormalizer.normalize(opmlItems)
|
||||
|
||||
// Combine all existing web feed URLs with all the new ones
|
||||
|
||||
var webFeedURLs = account.flattenedWebFeedURLs
|
||||
for opmlItem in normalizedItems {
|
||||
if let webFeedURL = opmlItem.feedSpecifier?.feedURL {
|
||||
webFeedURLs.insert(webFeedURL)
|
||||
} else {
|
||||
if let childItems = opmlItem.children {
|
||||
for childItem in childItems {
|
||||
if let webFeedURL = childItem.feedSpecifier?.feedURL {
|
||||
webFeedURLs.insert(webFeedURL)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
completion(.success(()))
|
||||
self.accountZone.importOPML(rootExternalID: rootExternalID, items: normalizedItems) { _ in
|
||||
self.refreshAll(for: account, downloadFeeds: false, completion: completion)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func createWebFeed(for account: Account, url urlString: String, name: String?, container: Container, completion: @escaping (Result<WebFeed, Error>) -> Void) {
|
||||
let editedName = name == nil || name!.isEmpty ? nil : name
|
||||
|
||||
guard let url = URL(string: urlString) else {
|
||||
completion(.failure(LocalAccountDelegateError.invalidParameter))
|
||||
return
|
||||
}
|
||||
|
||||
refreshProgress.addToNumberOfTasksAndRemaining(1)
|
||||
BatchUpdate.shared.start()
|
||||
refreshProgress.addToNumberOfTasksAndRemaining(3)
|
||||
FeedFinder.find(url: url) { result in
|
||||
|
||||
self.refreshProgress.completeTask()
|
||||
switch result {
|
||||
case .success(let feedSpecifiers):
|
||||
guard let bestFeedSpecifier = FeedSpecifier.bestFeed(in: feedSpecifiers),
|
||||
let url = URL(string: bestFeedSpecifier.urlString) else {
|
||||
self.refreshProgress.completeTask()
|
||||
guard let bestFeedSpecifier = FeedSpecifier.bestFeed(in: feedSpecifiers), let url = URL(string: bestFeedSpecifier.urlString) else {
|
||||
BatchUpdate.shared.end()
|
||||
self.refreshProgress.clear()
|
||||
completion(.failure(AccountError.createErrorNotFound))
|
||||
return
|
||||
}
|
||||
|
||||
if account.hasWebFeed(withURL: bestFeedSpecifier.urlString) {
|
||||
self.refreshProgress.completeTask()
|
||||
BatchUpdate.shared.end()
|
||||
self.refreshProgress.clear()
|
||||
completion(.failure(AccountError.createErrorAlreadySubscribed))
|
||||
return
|
||||
}
|
||||
|
||||
self.accountZone.createWebFeed(url: bestFeedSpecifier.urlString, editedName: editedName, container: container) { result in
|
||||
|
||||
self.refreshProgress.completeTask()
|
||||
switch result {
|
||||
case .success(let externalID):
|
||||
|
||||
let feed = account.createWebFeed(with: nil, url: url.absoluteString, webFeedID: url.absoluteString, homePageURL: nil)
|
||||
feed.editedName = editedName
|
||||
feed.externalID = externalID
|
||||
container.addWebFeed(feed)
|
||||
|
||||
InitialFeedDownloader.download(url) { parsedFeed in
|
||||
self.refreshProgress.completeTask()
|
||||
|
||||
if let parsedFeed = parsedFeed {
|
||||
account.update(feed, with: parsedFeed, {_ in})
|
||||
account.update(feed, with: parsedFeed, {_ in
|
||||
BatchUpdate.shared.end()
|
||||
completion(.success(feed))
|
||||
})
|
||||
}
|
||||
|
||||
feed.editedName = name
|
||||
|
||||
container.addWebFeed(feed)
|
||||
completion(.success(feed))
|
||||
}
|
||||
|
||||
case .failure(let error):
|
||||
BatchUpdate.shared.end()
|
||||
self.refreshProgress.clear()
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
|
||||
case .failure:
|
||||
self.refreshProgress.completeTask()
|
||||
BatchUpdate.shared.end()
|
||||
self.refreshProgress.clear()
|
||||
completion(.failure(AccountError.createErrorNotFound))
|
||||
}
|
||||
|
||||
|
@ -166,62 +274,224 @@ final class CloudKitAccountDelegate: AccountDelegate {
|
|||
}
|
||||
|
||||
func renameWebFeed(for account: Account, with feed: WebFeed, to name: String, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
let editedName = name.isEmpty ? nil : name
|
||||
refreshProgress.addToNumberOfTasksAndRemaining(1)
|
||||
accountZone.renameWebFeed(feed, editedName: editedName) { result in
|
||||
self.refreshProgress.completeTask()
|
||||
switch result {
|
||||
case .success:
|
||||
feed.editedName = name
|
||||
completion(.success(()))
|
||||
case .failure(let error):
|
||||
self.processAccountError(account, error)
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func removeWebFeed(for account: Account, with feed: WebFeed, from container: Container, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
refreshProgress.addToNumberOfTasksAndRemaining(1)
|
||||
accountZone.removeWebFeed(feed, from: container) { result in
|
||||
self.refreshProgress.completeTask()
|
||||
switch result {
|
||||
case .success:
|
||||
container.removeWebFeed(feed)
|
||||
completion(.success(()))
|
||||
case .failure(let error):
|
||||
self.processAccountError(account, error)
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func moveWebFeed(for account: Account, with feed: WebFeed, from: Container, to: Container, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
from.removeWebFeed(feed)
|
||||
to.addWebFeed(feed)
|
||||
func moveWebFeed(for account: Account, with feed: WebFeed, from fromContainer: Container, to toContainer: Container, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
refreshProgress.addToNumberOfTasksAndRemaining(1)
|
||||
accountZone.moveWebFeed(feed, from: fromContainer, to: toContainer) { result in
|
||||
self.refreshProgress.completeTask()
|
||||
switch result {
|
||||
case .success:
|
||||
fromContainer.removeWebFeed(feed)
|
||||
toContainer.addWebFeed(feed)
|
||||
completion(.success(()))
|
||||
case .failure(let error):
|
||||
self.processAccountError(account, error)
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func addWebFeed(for account: Account, with feed: WebFeed, to container: Container, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
refreshProgress.addToNumberOfTasksAndRemaining(1)
|
||||
accountZone.addWebFeed(feed, to: container) { result in
|
||||
self.refreshProgress.completeTask()
|
||||
switch result {
|
||||
case .success:
|
||||
container.addWebFeed(feed)
|
||||
completion(.success(()))
|
||||
case .failure(let error):
|
||||
self.processAccountError(account, error)
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func restoreWebFeed(for account: Account, feed: WebFeed, container: Container, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
refreshProgress.addToNumberOfTasksAndRemaining(1)
|
||||
accountZone.createWebFeed(url: feed.url, editedName: feed.editedName, container: container) { result in
|
||||
self.refreshProgress.completeTask()
|
||||
switch result {
|
||||
case .success(let externalID):
|
||||
feed.externalID = externalID
|
||||
container.addWebFeed(feed)
|
||||
completion(.success(()))
|
||||
case .failure(let error):
|
||||
self.processAccountError(account, error)
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func addFolder(for account: Account, name: String, completion: @escaping (Result<Folder, Error>) -> Void) {
|
||||
func createFolder(for account: Account, name: String, completion: @escaping (Result<Folder, Error>) -> Void) {
|
||||
refreshProgress.addToNumberOfTasksAndRemaining(1)
|
||||
accountZone.createFolder(name: name) { result in
|
||||
self.refreshProgress.completeTask()
|
||||
switch result {
|
||||
case .success(let externalID):
|
||||
if let folder = account.ensureFolder(with: name) {
|
||||
folder.externalID = externalID
|
||||
completion(.success(folder))
|
||||
} else {
|
||||
completion(.failure(FeedbinAccountDelegateError.invalidParameter))
|
||||
}
|
||||
case .failure(let error):
|
||||
self.processAccountError(account, error)
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func renameFolder(for account: Account, with folder: Folder, to name: String, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
refreshProgress.addToNumberOfTasksAndRemaining(1)
|
||||
accountZone.renameFolder(folder, to: name) { result in
|
||||
self.refreshProgress.completeTask()
|
||||
switch result {
|
||||
case .success:
|
||||
folder.name = name
|
||||
completion(.success(()))
|
||||
case .failure(let error):
|
||||
self.processAccountError(account, error)
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func removeFolder(for account: Account, with folder: Folder, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
refreshProgress.addToNumberOfTasksAndRemaining(1)
|
||||
accountZone.removeFolder(folder) { result in
|
||||
self.refreshProgress.completeTask()
|
||||
switch result {
|
||||
case .success:
|
||||
account.removeFolder(folder)
|
||||
completion(.success(()))
|
||||
case .failure(let error):
|
||||
self.processAccountError(account, error)
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func restoreFolder(for account: Account, folder: Folder, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
guard let name = folder.name else {
|
||||
completion(.failure(LocalAccountDelegateError.invalidParameter))
|
||||
return
|
||||
}
|
||||
|
||||
let feedsToRestore = folder.topLevelWebFeeds
|
||||
refreshProgress.addToNumberOfTasksAndRemaining(1 + feedsToRestore.count)
|
||||
|
||||
accountZone.createFolder(name: name) { result in
|
||||
self.refreshProgress.completeTask()
|
||||
switch result {
|
||||
case .success(let externalID):
|
||||
folder.externalID = externalID
|
||||
account.addFolder(folder)
|
||||
|
||||
let group = DispatchGroup()
|
||||
for feed in feedsToRestore {
|
||||
|
||||
folder.topLevelWebFeeds.remove(feed)
|
||||
|
||||
group.enter()
|
||||
self.restoreWebFeed(for: account, feed: feed, container: folder) { result in
|
||||
self.refreshProgress.completeTask()
|
||||
group.leave()
|
||||
switch result {
|
||||
case .success:
|
||||
break
|
||||
case .failure(let error):
|
||||
os_log(.error, log: self.log, "Restore folder feed error: %@.", error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
group.notify(queue: DispatchQueue.main) {
|
||||
account.addFolder(folder)
|
||||
completion(.success(()))
|
||||
}
|
||||
|
||||
case .failure(let error):
|
||||
self.processAccountError(account, error)
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func markArticles(for account: Account, articles: Set<Article>, statusKey: ArticleStatus.Key, flag: Bool) -> Set<Article>? {
|
||||
let syncStatuses = articles.map { article in
|
||||
return SyncStatus(articleID: article.articleID, key: statusKey, flag: flag)
|
||||
}
|
||||
database.insertStatuses(syncStatuses)
|
||||
|
||||
database.selectPendingCount { result in
|
||||
if let count = try? result.get(), count > 100 {
|
||||
self.sendArticleStatus(for: account) { _ in }
|
||||
}
|
||||
}
|
||||
|
||||
return try? account.update(articles, statusKey: statusKey, flag: flag)
|
||||
}
|
||||
|
||||
func accountDidInitialize(_ account: Account) {
|
||||
accountZone.delegate = CloudKitAcountZoneDelegate(account: account, refreshProgress: refreshProgress)
|
||||
articlesZone.delegate = CloudKitArticlesZoneDelegate(account: account, database: database, articlesZone: articlesZone)
|
||||
|
||||
// Check to see if this is a new account and initialize anything we need
|
||||
if account.externalID == nil {
|
||||
accountZone.findOrCreateAccount() { result in
|
||||
switch result {
|
||||
case .success(let externalID):
|
||||
account.externalID = externalID
|
||||
self.refreshAll(for: account, downloadFeeds: false) { result in
|
||||
if case .failure(let error) = result {
|
||||
os_log(.error, log: self.log, "Error while doing intial refresh: %@", error.localizedDescription)
|
||||
}
|
||||
}
|
||||
case .failure(let error):
|
||||
os_log(.error, log: self.log, "Error adding account container: %@", error.localizedDescription)
|
||||
}
|
||||
}
|
||||
zones.forEach { zone in
|
||||
zone.subscribeToZoneChanges()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func accountWillBeDeleted(_ account: Account) {
|
||||
zones.forEach { zone in
|
||||
zone.resetChangeToken()
|
||||
}
|
||||
}
|
||||
|
||||
static func validateCredentials(transport: Transport, credentials: Credentials, endpoint: URL? = nil, completion: (Result<Credentials?, Error>) -> Void) {
|
||||
|
@ -235,10 +505,86 @@ final class CloudKitAccountDelegate: AccountDelegate {
|
|||
}
|
||||
|
||||
func suspendDatabase() {
|
||||
// Nothing to do
|
||||
database.suspend()
|
||||
}
|
||||
|
||||
func resume() {
|
||||
refresher.resume()
|
||||
database.resume()
|
||||
}
|
||||
}
|
||||
|
||||
private extension CloudKitAccountDelegate {
|
||||
|
||||
func refreshAll(for account: Account, downloadFeeds: Bool, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
|
||||
let intialWebFeedsCount = downloadFeeds ? account.flattenedWebFeeds().count : 0
|
||||
refreshProgress.addToNumberOfTasksAndRemaining(3 + intialWebFeedsCount)
|
||||
|
||||
BatchUpdate.shared.start()
|
||||
accountZone.fetchChangesInZone() { result in
|
||||
BatchUpdate.shared.end()
|
||||
switch result {
|
||||
case .success:
|
||||
|
||||
let webFeeds = account.flattenedWebFeeds()
|
||||
if downloadFeeds {
|
||||
self.refreshProgress.addToNumberOfTasksAndRemaining(webFeeds.count - intialWebFeedsCount)
|
||||
}
|
||||
|
||||
self.refreshProgress.completeTask()
|
||||
self.sendArticleStatus(for: account) { result in
|
||||
switch result {
|
||||
case .success:
|
||||
|
||||
self.refreshProgress.completeTask()
|
||||
self.refreshArticleStatus(for: account) { result in
|
||||
switch result {
|
||||
case .success:
|
||||
|
||||
self.refreshProgress.completeTask()
|
||||
|
||||
guard downloadFeeds else {
|
||||
completion(.success(()))
|
||||
return
|
||||
}
|
||||
|
||||
self.refresher.refreshFeeds(webFeeds, feedCompletionBlock: { _ in self.refreshProgress.completeTask() }) {
|
||||
account.metadata.lastArticleFetchEndTime = Date()
|
||||
self.refreshProgress.clear()
|
||||
completion(.success(()))
|
||||
}
|
||||
|
||||
case .failure(let error):
|
||||
self.processAccountError(account, error)
|
||||
self.refreshProgress.clear()
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
|
||||
case .failure(let error):
|
||||
self.processAccountError(account, error)
|
||||
self.refreshProgress.clear()
|
||||
completion(.failure(error))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
case .failure(let error):
|
||||
self.processAccountError(account, error)
|
||||
self.refreshProgress.clear()
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func processAccountError(_ account: Account, _ error: Error) {
|
||||
if case CloudKitZoneError.userDeletedZone = error {
|
||||
account.removeFeeds(account.topLevelWebFeeds)
|
||||
for folder in account.folders ?? Set<Folder>() {
|
||||
account.removeFolder(folder)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -7,6 +7,9 @@
|
|||
//
|
||||
|
||||
import Foundation
|
||||
import os.log
|
||||
import RSWeb
|
||||
import RSParser
|
||||
import CloudKit
|
||||
|
||||
final class CloudKitAccountZone: CloudKitZone {
|
||||
|
@ -15,79 +18,317 @@ final class CloudKitAccountZone: CloudKitZone {
|
|||
return CKRecordZone.ID(zoneName: "Account", ownerName: CKCurrentUserDefaultName)
|
||||
}
|
||||
|
||||
let container: CKContainer
|
||||
let database: CKDatabase
|
||||
var log = OSLog(subsystem: Bundle.main.bundleIdentifier!, category: "CloudKit")
|
||||
|
||||
weak var container: CKContainer?
|
||||
weak var database: CKDatabase?
|
||||
var delegate: CloudKitZoneDelegate?
|
||||
|
||||
struct CloudKitWebFeed {
|
||||
static let recordType = "AccountWebFeed"
|
||||
struct Fields {
|
||||
static let url = "url"
|
||||
static let editedName = "editedName"
|
||||
static let containerExternalIDs = "containerExternalIDs"
|
||||
}
|
||||
}
|
||||
|
||||
struct CloudKitContainer {
|
||||
static let recordType = "AccountContainer"
|
||||
struct Fields {
|
||||
static let isAccount = "isAccount"
|
||||
static let name = "name"
|
||||
}
|
||||
}
|
||||
|
||||
init(container: CKContainer) {
|
||||
self.container = container
|
||||
self.database = container.privateCloudDatabase
|
||||
}
|
||||
|
||||
/// Persist a feed record to iCloud and return the external key
|
||||
func createFeed(url: String, editedName: String?, completion: @escaping (Result<String, Error>) -> Void) {
|
||||
let record = CKRecord(recordType: "Feed", recordID: generateRecordID())
|
||||
record["url"] = url
|
||||
func importOPML(rootExternalID: String, items: [RSOPMLItem], completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
var records = [CKRecord]()
|
||||
var feedRecords = [String: CKRecord]()
|
||||
|
||||
func processFeed(feedSpecifier: RSOPMLFeedSpecifier, containerExternalID: String) {
|
||||
if let webFeedRecord = feedRecords[feedSpecifier.feedURL], var containerExternalIDs = webFeedRecord[CloudKitWebFeed.Fields.containerExternalIDs] as? [String] {
|
||||
containerExternalIDs.append(containerExternalID)
|
||||
webFeedRecord[CloudKitWebFeed.Fields.containerExternalIDs] = containerExternalIDs
|
||||
} else {
|
||||
let webFeedRecord = newWebFeedCKRecord(feedSpecifier: feedSpecifier, containerExternalID: containerExternalID)
|
||||
records.append(webFeedRecord)
|
||||
feedRecords[feedSpecifier.feedURL] = webFeedRecord
|
||||
}
|
||||
}
|
||||
|
||||
for item in items {
|
||||
if let feedSpecifier = item.feedSpecifier {
|
||||
processFeed(feedSpecifier: feedSpecifier, containerExternalID: rootExternalID)
|
||||
} else {
|
||||
if let title = item.titleFromAttributes {
|
||||
let containerRecord = newContainerCKRecord(name: title)
|
||||
records.append(containerRecord)
|
||||
item.children?.forEach { itemChild in
|
||||
if let feedSpecifier = itemChild.feedSpecifier {
|
||||
processFeed(feedSpecifier: feedSpecifier, containerExternalID: containerRecord.externalID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
save(records, completion: completion)
|
||||
}
|
||||
|
||||
/// Persist a web feed record to iCloud and return the external key
|
||||
func createWebFeed(url: String, editedName: String?, container: Container, completion: @escaping (Result<String, Error>) -> Void) {
|
||||
let recordID = CKRecord.ID(recordName: url.md5String, zoneID: Self.zoneID)
|
||||
let record = CKRecord(recordType: CloudKitWebFeed.recordType, recordID: recordID)
|
||||
record[CloudKitWebFeed.Fields.url] = url
|
||||
if let editedName = editedName {
|
||||
record["editedName"] = editedName
|
||||
record[CloudKitWebFeed.Fields.editedName] = editedName
|
||||
}
|
||||
|
||||
save(record: record, completion: completion)
|
||||
guard let containerExternalID = container.externalID else {
|
||||
completion(.failure(CloudKitZoneError.invalidParameter))
|
||||
return
|
||||
}
|
||||
record[CloudKitWebFeed.Fields.containerExternalIDs] = [containerExternalID]
|
||||
|
||||
save(record) { result in
|
||||
switch result {
|
||||
case .success:
|
||||
completion(.success(record.externalID))
|
||||
case .failure(let error):
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// private func fetchChangesInZones(_ callback: ((Error?) -> Void)? = nil) {
|
||||
// let changesOp = CKFetchRecordZoneChangesOperation(recordZoneIDs: zoneIds, optionsByRecordZoneID: zoneIdOptions)
|
||||
// changesOp.fetchAllChanges = true
|
||||
//
|
||||
// changesOp.recordZoneChangeTokensUpdatedBlock = { [weak self] zoneId, token, _ in
|
||||
// guard let self = self else { return }
|
||||
// guard let syncObject = self.syncObjects.first(where: { $0.zoneID == zoneId }) else { return }
|
||||
// syncObject.zoneChangesToken = token
|
||||
// }
|
||||
//
|
||||
// changesOp.recordChangedBlock = { [weak self] record in
|
||||
// /// The Cloud will return the modified record since the last zoneChangesToken, we need to do local cache here.
|
||||
// /// Handle the record:
|
||||
// guard let self = self else { return }
|
||||
// guard let syncObject = self.syncObjects.first(where: { $0.recordType == record.recordType }) else { return }
|
||||
// syncObject.add(record: record)
|
||||
// }
|
||||
//
|
||||
// changesOp.recordWithIDWasDeletedBlock = { [weak self] recordId, _ in
|
||||
// guard let self = self else { return }
|
||||
// guard let syncObject = self.syncObjects.first(where: { $0.zoneID == recordId.zoneID }) else { return }
|
||||
// syncObject.delete(recordID: recordId)
|
||||
// }
|
||||
//
|
||||
// changesOp.recordZoneFetchCompletionBlock = { [weak self](zoneId ,token, _, _, error) in
|
||||
// guard let self = self else { return }
|
||||
// switch ErrorHandler.shared.resultType(with: error) {
|
||||
// case .success:
|
||||
// guard let syncObject = self.syncObjects.first(where: { $0.zoneID == zoneId }) else { return }
|
||||
// syncObject.zoneChangesToken = token
|
||||
// case .retry(let timeToWait, _):
|
||||
// ErrorHandler.shared.retryOperationIfPossible(retryAfter: timeToWait, block: {
|
||||
// self.fetchChangesInZones(callback)
|
||||
// })
|
||||
// case .recoverableError(let reason, _):
|
||||
// switch reason {
|
||||
// case .changeTokenExpired:
|
||||
// /// The previousServerChangeToken value is too old and the client must re-sync from scratch
|
||||
// guard let syncObject = self.syncObjects.first(where: { $0.zoneID == zoneId }) else { return }
|
||||
// syncObject.zoneChangesToken = nil
|
||||
// self.fetchChangesInZones(callback)
|
||||
// default:
|
||||
// return
|
||||
// }
|
||||
// default:
|
||||
// return
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// changesOp.fetchRecordZoneChangesCompletionBlock = { error in
|
||||
// callback?(error)
|
||||
// }
|
||||
//
|
||||
// database.add(changesOp)
|
||||
// }
|
||||
/// Rename the given web feed
|
||||
func renameWebFeed(_ webFeed: WebFeed, editedName: String?, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
guard let externalID = webFeed.externalID else {
|
||||
completion(.failure(CloudKitZoneError.invalidParameter))
|
||||
return
|
||||
}
|
||||
|
||||
let recordID = CKRecord.ID(recordName: externalID, zoneID: Self.zoneID)
|
||||
let record = CKRecord(recordType: CloudKitWebFeed.recordType, recordID: recordID)
|
||||
record[CloudKitWebFeed.Fields.editedName] = editedName
|
||||
|
||||
save(record) { result in
|
||||
switch result {
|
||||
case .success:
|
||||
completion(.success(()))
|
||||
case .failure(let error):
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Removes a web feed from a container and optionally deletes it, calling the completion with true if deleted
|
||||
func removeWebFeed(_ webFeed: WebFeed, from: Container, completion: @escaping (Result<Bool, Error>) -> Void) {
|
||||
guard let fromContainerExternalID = from.externalID else {
|
||||
completion(.failure(CloudKitZoneError.invalidParameter))
|
||||
return
|
||||
}
|
||||
|
||||
fetch(externalID: webFeed.externalID) { result in
|
||||
switch result {
|
||||
case .success(let record):
|
||||
|
||||
if let containerExternalIDs = record[CloudKitWebFeed.Fields.containerExternalIDs] as? [String] {
|
||||
var containerExternalIDSet = Set(containerExternalIDs)
|
||||
containerExternalIDSet.remove(fromContainerExternalID)
|
||||
|
||||
if containerExternalIDSet.isEmpty {
|
||||
self.delete(externalID: webFeed.externalID) { result in
|
||||
switch result {
|
||||
case .success:
|
||||
completion(.success(true))
|
||||
case .failure(let error):
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
|
||||
} else {
|
||||
|
||||
record[CloudKitWebFeed.Fields.containerExternalIDs] = Array(containerExternalIDSet)
|
||||
self.save(record) { result in
|
||||
switch result {
|
||||
case .success:
|
||||
completion(.success(false))
|
||||
case .failure(let error):
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
case .failure(let error):
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func moveWebFeed(_ webFeed: WebFeed, from: Container, to: Container, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
guard let fromContainerExternalID = from.externalID, let toContainerExternalID = to.externalID else {
|
||||
completion(.failure(CloudKitZoneError.invalidParameter))
|
||||
return
|
||||
}
|
||||
|
||||
fetch(externalID: webFeed.externalID) { result in
|
||||
switch result {
|
||||
case .success(let record):
|
||||
if let containerExternalIDs = record[CloudKitWebFeed.Fields.containerExternalIDs] as? [String] {
|
||||
var containerExternalIDSet = Set(containerExternalIDs)
|
||||
containerExternalIDSet.remove(fromContainerExternalID)
|
||||
containerExternalIDSet.insert(toContainerExternalID)
|
||||
record[CloudKitWebFeed.Fields.containerExternalIDs] = Array(containerExternalIDSet)
|
||||
self.save(record, completion: completion)
|
||||
}
|
||||
case .failure(let error):
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func addWebFeed(_ webFeed: WebFeed, to: Container, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
guard let toContainerExternalID = to.externalID else {
|
||||
completion(.failure(CloudKitZoneError.invalidParameter))
|
||||
return
|
||||
}
|
||||
|
||||
fetch(externalID: webFeed.externalID) { result in
|
||||
switch result {
|
||||
case .success(let record):
|
||||
if let containerExternalIDs = record[CloudKitWebFeed.Fields.containerExternalIDs] as? [String] {
|
||||
var containerExternalIDSet = Set(containerExternalIDs)
|
||||
containerExternalIDSet.insert(toContainerExternalID)
|
||||
record[CloudKitWebFeed.Fields.containerExternalIDs] = Array(containerExternalIDSet)
|
||||
self.save(record, completion: completion)
|
||||
}
|
||||
case .failure(let error):
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func findOrCreateAccount(completion: @escaping (Result<String, Error>) -> Void) {
|
||||
let predicate = NSPredicate(format: "isAccount = \"1\"")
|
||||
let ckQuery = CKQuery(recordType: CloudKitContainer.recordType, predicate: predicate)
|
||||
|
||||
database?.perform(ckQuery, inZoneWith: Self.zoneID) { [weak self] records, error in
|
||||
guard let self = self else { return }
|
||||
|
||||
switch CloudKitZoneResult.resolve(error) {
|
||||
case .success:
|
||||
DispatchQueue.main.async {
|
||||
if records!.count > 0 {
|
||||
completion(.success(records![0].externalID))
|
||||
} else {
|
||||
self.createContainer(name: "Account", isAccount: true, completion: completion)
|
||||
}
|
||||
}
|
||||
case .retry(let timeToWait):
|
||||
self.retryIfPossible(after: timeToWait) {
|
||||
self.findOrCreateAccount(completion: completion)
|
||||
}
|
||||
case .zoneNotFound, .userDeletedZone:
|
||||
self.createZoneRecord() { result in
|
||||
switch result {
|
||||
case .success:
|
||||
self.findOrCreateAccount(completion: completion)
|
||||
case .failure(let error):
|
||||
DispatchQueue.main.async {
|
||||
completion(.failure(CloudKitError(error)))
|
||||
}
|
||||
}
|
||||
}
|
||||
default:
|
||||
DispatchQueue.main.async {
|
||||
completion(.failure(CloudKitError(error!)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
query(ckQuery) { result in
|
||||
switch result {
|
||||
case .success(let records):
|
||||
if records.count > 0 {
|
||||
completion(.success(records[0].externalID))
|
||||
} else {
|
||||
self.createContainer(name: "Account", isAccount: true, completion: completion)
|
||||
}
|
||||
case .failure:
|
||||
self.createContainer(name: "Account", isAccount: true, completion: completion)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func createFolder(name: String, completion: @escaping (Result<String, Error>) -> Void) {
|
||||
createContainer(name: name, isAccount: false, completion: completion)
|
||||
}
|
||||
|
||||
func renameFolder(_ folder: Folder, to name: String, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
guard let externalID = folder.externalID else {
|
||||
completion(.failure(CloudKitZoneError.invalidParameter))
|
||||
return
|
||||
}
|
||||
|
||||
let recordID = CKRecord.ID(recordName: externalID, zoneID: Self.zoneID)
|
||||
let record = CKRecord(recordType: CloudKitContainer.recordType, recordID: recordID)
|
||||
record[CloudKitContainer.Fields.name] = name
|
||||
|
||||
save(record) { result in
|
||||
switch result {
|
||||
case .success:
|
||||
completion(.success(()))
|
||||
case .failure(let error):
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func removeFolder(_ folder: Folder, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
delete(externalID: folder.externalID, completion: completion)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private extension CloudKitAccountZone {
|
||||
|
||||
func newWebFeedCKRecord(feedSpecifier: RSOPMLFeedSpecifier, containerExternalID: String) -> CKRecord {
|
||||
let record = CKRecord(recordType: CloudKitWebFeed.recordType, recordID: generateRecordID())
|
||||
record[CloudKitWebFeed.Fields.url] = feedSpecifier.feedURL
|
||||
if let editedName = feedSpecifier.title {
|
||||
record[CloudKitWebFeed.Fields.editedName] = editedName
|
||||
}
|
||||
record[CloudKitWebFeed.Fields.containerExternalIDs] = [containerExternalID]
|
||||
return record
|
||||
}
|
||||
|
||||
func newContainerCKRecord(name: String) -> CKRecord {
|
||||
let record = CKRecord(recordType: CloudKitContainer.recordType, recordID: generateRecordID())
|
||||
record[CloudKitContainer.Fields.name] = name
|
||||
record[CloudKitContainer.Fields.isAccount] = "0"
|
||||
return record
|
||||
}
|
||||
|
||||
func createContainer(name: String, isAccount: Bool, completion: @escaping (Result<String, Error>) -> Void) {
|
||||
let record = CKRecord(recordType: CloudKitContainer.recordType, recordID: generateRecordID())
|
||||
record[CloudKitContainer.Fields.name] = name
|
||||
record[CloudKitContainer.Fields.isAccount] = isAccount ? "1" : "0"
|
||||
|
||||
save(record) { result in
|
||||
switch result {
|
||||
case .success:
|
||||
completion(.success(record.externalID))
|
||||
case .failure(let error):
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -0,0 +1,176 @@
|
|||
//
|
||||
// CloudKitAccountZoneDelegate.swift
|
||||
// Account
|
||||
//
|
||||
// Created by Maurice Parker on 3/29/20.
|
||||
// Copyright © 2020 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import os.log
|
||||
import RSWeb
|
||||
import CloudKit
|
||||
|
||||
class CloudKitAcountZoneDelegate: CloudKitZoneDelegate {
|
||||
|
||||
private typealias UnclaimedWebFeed = (url: URL, editedName: String?, webFeedExternalID: String)
|
||||
private var unclaimedWebFeeds = [String: [UnclaimedWebFeed]]()
|
||||
|
||||
private var log = OSLog(subsystem: Bundle.main.bundleIdentifier!, category: "CloudKit")
|
||||
|
||||
weak var account: Account?
|
||||
weak var refreshProgress: DownloadProgress?
|
||||
|
||||
init(account: Account, refreshProgress: DownloadProgress) {
|
||||
self.account = account
|
||||
self.refreshProgress = refreshProgress
|
||||
}
|
||||
|
||||
func cloudKitDidChange(record: CKRecord) {
|
||||
switch record.recordType {
|
||||
case CloudKitAccountZone.CloudKitWebFeed.recordType:
|
||||
addOrUpdateWebFeed(record)
|
||||
case CloudKitAccountZone.CloudKitContainer.recordType:
|
||||
addOrUpdateContainer(record)
|
||||
default:
|
||||
assertionFailure("Unknown record type: \(record.recordType)")
|
||||
}
|
||||
}
|
||||
|
||||
func cloudKitDidDelete(recordKey: CloudKitRecordKey) {
|
||||
switch recordKey.recordType {
|
||||
case CloudKitAccountZone.CloudKitWebFeed.recordType:
|
||||
removeWebFeed(recordKey.recordID.externalID)
|
||||
case CloudKitAccountZone.CloudKitContainer.recordType:
|
||||
removeContainer(recordKey.recordID.externalID)
|
||||
default:
|
||||
assertionFailure("Unknown record type: \(recordKey.recordType)")
|
||||
}
|
||||
}
|
||||
|
||||
func cloudKitDidModify(changed: [CKRecord], deleted: [CloudKitRecordKey], completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
completion(.success(()))
|
||||
}
|
||||
|
||||
func addOrUpdateWebFeed(_ record: CKRecord) {
|
||||
guard let account = account,
|
||||
let urlString = record[CloudKitAccountZone.CloudKitWebFeed.Fields.url] as? String,
|
||||
let containerExternalIDs = record[CloudKitAccountZone.CloudKitWebFeed.Fields.containerExternalIDs] as? [String],
|
||||
let url = URL(string: urlString) else { return }
|
||||
|
||||
let editedName = record[CloudKitAccountZone.CloudKitWebFeed.Fields.editedName] as? String
|
||||
|
||||
if let webFeed = account.existingWebFeed(withExternalID: record.externalID) {
|
||||
updateWebFeed(webFeed, editedName: editedName, containerExternalIDs: containerExternalIDs)
|
||||
} else {
|
||||
var webFeed: WebFeed? = nil
|
||||
for containerExternalID in containerExternalIDs {
|
||||
if let container = account.existingContainer(withExternalID: containerExternalID) {
|
||||
if webFeed == nil {
|
||||
webFeed = createWebFeed(url: url, editedName: editedName, webFeedExternalID: record.externalID)
|
||||
}
|
||||
container.addWebFeed(webFeed!)
|
||||
} else {
|
||||
addUnclaimedWebFeed(url: url, editedName: editedName, webFeedExternalID: record.externalID, containerExternalID: containerExternalID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func removeWebFeed(_ externalID: String) {
|
||||
if let webFeed = account?.existingWebFeed(withExternalID: externalID), let containers = account?.existingContainers(withWebFeed: webFeed) {
|
||||
containers.forEach { $0.removeWebFeed(webFeed) }
|
||||
}
|
||||
}
|
||||
|
||||
func addOrUpdateContainer(_ record: CKRecord) {
|
||||
guard let account = account,
|
||||
let name = record[CloudKitAccountZone.CloudKitContainer.Fields.name] as? String,
|
||||
let isAccount = record[CloudKitAccountZone.CloudKitContainer.Fields.isAccount] as? String,
|
||||
isAccount != "1" else { return }
|
||||
|
||||
var folder = account.existingFolder(withExternalID: record.externalID)
|
||||
folder?.name = name
|
||||
|
||||
if folder == nil {
|
||||
folder = account.ensureFolder(with: name)
|
||||
folder?.externalID = record.externalID
|
||||
}
|
||||
|
||||
if let folder = folder, let containerExternalID = folder.externalID, let unclaimedWebFeeds = unclaimedWebFeeds[containerExternalID] {
|
||||
for unclaimedWebFeed in unclaimedWebFeeds {
|
||||
var webFeed = account.existingWebFeed(withExternalID: unclaimedWebFeed.webFeedExternalID)
|
||||
if webFeed == nil {
|
||||
webFeed = createWebFeed(url: unclaimedWebFeed.url, editedName: unclaimedWebFeed.editedName, webFeedExternalID: unclaimedWebFeed.webFeedExternalID)
|
||||
}
|
||||
if let webFeed = webFeed {
|
||||
folder.addWebFeed(webFeed)
|
||||
}
|
||||
}
|
||||
self.unclaimedWebFeeds.removeValue(forKey: containerExternalID)
|
||||
}
|
||||
}
|
||||
|
||||
func removeContainer(_ externalID: String) {
|
||||
if let folder = account?.existingFolder(withExternalID: externalID) {
|
||||
account?.removeFolder(folder)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private extension CloudKitAcountZoneDelegate {
|
||||
|
||||
func updateWebFeed(_ webFeed: WebFeed, editedName: String?, containerExternalIDs: [String]) {
|
||||
guard let account = account else { return }
|
||||
webFeed.editedName = editedName
|
||||
|
||||
let existingContainers = account.existingContainers(withWebFeed: webFeed)
|
||||
let existingContainerExternalIds = existingContainers.compactMap { $0.externalID }
|
||||
|
||||
let diff = containerExternalIDs.difference(from: existingContainerExternalIds)
|
||||
|
||||
for change in diff {
|
||||
switch change {
|
||||
case .remove(_, let externalID, _):
|
||||
if let container = existingContainers.first(where: { $0.externalID == externalID }) {
|
||||
container.removeWebFeed(webFeed)
|
||||
}
|
||||
case .insert(_, let externalID, _):
|
||||
if let container = account.existingContainer(withExternalID: externalID) {
|
||||
container.addWebFeed(webFeed)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func createWebFeed(url: URL, editedName: String?, webFeedExternalID: String) -> WebFeed? {
|
||||
guard let account = account else { return nil }
|
||||
|
||||
let webFeed = account.createWebFeed(with: editedName, url: url.absoluteString, webFeedID: url.absoluteString, homePageURL: nil)
|
||||
webFeed.editedName = editedName
|
||||
webFeed.externalID = webFeedExternalID
|
||||
|
||||
refreshProgress?.addToNumberOfTasksAndRemaining(1)
|
||||
InitialFeedDownloader.download(url) { parsedFeed in
|
||||
self.refreshProgress?.completeTask()
|
||||
if let parsedFeed = parsedFeed {
|
||||
account.update(webFeed, with: parsedFeed, {_ in })
|
||||
}
|
||||
}
|
||||
|
||||
return webFeed
|
||||
}
|
||||
|
||||
func addUnclaimedWebFeed(url: URL, editedName: String?, webFeedExternalID: String, containerExternalID: String) {
|
||||
if var unclaimedWebFeeds = self.unclaimedWebFeeds[containerExternalID] {
|
||||
unclaimedWebFeeds.append(UnclaimedWebFeed(url: url, editedName: editedName, webFeedExternalID: webFeedExternalID))
|
||||
self.unclaimedWebFeeds[containerExternalID] = unclaimedWebFeeds
|
||||
} else {
|
||||
var unclaimedWebFeeds = [UnclaimedWebFeed]()
|
||||
unclaimedWebFeeds.append(UnclaimedWebFeed(url: url, editedName: editedName, webFeedExternalID: webFeedExternalID))
|
||||
self.unclaimedWebFeeds[containerExternalID] = unclaimedWebFeeds
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,226 @@
|
|||
//
|
||||
// CloudKitArticlesZone.swift
|
||||
// Account
|
||||
//
|
||||
// Created by Maurice Parker on 4/1/20.
|
||||
// Copyright © 2020 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import os.log
|
||||
import RSParser
|
||||
import RSWeb
|
||||
import CloudKit
|
||||
import Articles
|
||||
import SyncDatabase
|
||||
|
||||
final class CloudKitArticlesZone: CloudKitZone {
|
||||
|
||||
static var zoneID: CKRecordZone.ID {
|
||||
return CKRecordZone.ID(zoneName: "Articles", ownerName: CKCurrentUserDefaultName)
|
||||
}
|
||||
|
||||
var log = OSLog(subsystem: Bundle.main.bundleIdentifier!, category: "CloudKit")
|
||||
|
||||
weak var container: CKContainer?
|
||||
weak var database: CKDatabase?
|
||||
var delegate: CloudKitZoneDelegate? = nil
|
||||
|
||||
struct CloudKitArticle {
|
||||
static let recordType = "Article"
|
||||
struct Fields {
|
||||
static let articleStatus = "articleStatus"
|
||||
static let webFeedURL = "webFeedURL"
|
||||
static let uniqueID = "uniqueID"
|
||||
static let title = "title"
|
||||
static let contentHTML = "contentHTML"
|
||||
static let contentText = "contentText"
|
||||
static let url = "url"
|
||||
static let externalURL = "externalURL"
|
||||
static let summary = "summary"
|
||||
static let imageURL = "imageURL"
|
||||
static let datePublished = "datePublished"
|
||||
static let dateModified = "dateModified"
|
||||
static let parsedAuthors = "parsedAuthors"
|
||||
}
|
||||
}
|
||||
|
||||
struct CloudKitArticleStatus {
|
||||
static let recordType = "ArticleStatus"
|
||||
struct Fields {
|
||||
static let read = "read"
|
||||
static let starred = "starred"
|
||||
static let userDeleted = "userDeleted"
|
||||
}
|
||||
}
|
||||
|
||||
init(container: CKContainer) {
|
||||
self.container = container
|
||||
self.database = container.privateCloudDatabase
|
||||
}
|
||||
|
||||
func refreshArticleStatus(completion: @escaping ((Result<Void, Error>) -> Void)) {
|
||||
fetchChangesInZone() { result in
|
||||
switch result {
|
||||
case .success:
|
||||
completion(.success(()))
|
||||
case .failure(let error):
|
||||
if case CloudKitZoneError.userDeletedZone = error {
|
||||
self.createZoneRecord() { result in
|
||||
switch result {
|
||||
case .success:
|
||||
self.refreshArticleStatus(completion: completion)
|
||||
case .failure(let error):
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func sendArticleStatus(_ syncStatuses: [SyncStatus], starredArticles: Set<Article>, completion: @escaping ((Result<Void, Error>) -> Void)) {
|
||||
var records = makeStatusRecords(syncStatuses)
|
||||
makeArticleRecordsIfNecessary(starredArticles) { result in
|
||||
switch result {
|
||||
case .success(let articleRecords):
|
||||
records.append(contentsOf: articleRecords)
|
||||
self.modify(recordsToSave: records, recordIDsToDelete: []) { result in
|
||||
switch result {
|
||||
case .success:
|
||||
completion(.success(()))
|
||||
case .failure(let error):
|
||||
self.handleSendArticleStatusError(error, syncStatuses: syncStatuses, starredArticles: starredArticles, completion: completion)
|
||||
}
|
||||
}
|
||||
case .failure(let error):
|
||||
self.handleSendArticleStatusError(error, syncStatuses: syncStatuses, starredArticles: starredArticles, completion: completion)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func handleSendArticleStatusError(_ error: Error, syncStatuses: [SyncStatus], starredArticles: Set<Article>, completion: @escaping ((Result<Void, Error>) -> Void)) {
|
||||
if case CloudKitZoneError.userDeletedZone = error {
|
||||
self.createZoneRecord() { result in
|
||||
switch result {
|
||||
case .success:
|
||||
self.sendArticleStatus(syncStatuses, starredArticles: starredArticles, completion: completion)
|
||||
case .failure(let error):
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private extension CloudKitArticlesZone {
|
||||
|
||||
func makeStatusRecords(_ syncStatuses: [SyncStatus]) -> [CKRecord] {
|
||||
var records = [String: CKRecord]()
|
||||
|
||||
for status in syncStatuses {
|
||||
|
||||
var record = records[status.articleID]
|
||||
if record == nil {
|
||||
let recordID = CKRecord.ID(recordName: status.articleID, zoneID: Self.zoneID)
|
||||
record = CKRecord(recordType: CloudKitArticleStatus.recordType, recordID: recordID)
|
||||
records[status.articleID] = record
|
||||
}
|
||||
|
||||
switch status.key {
|
||||
case .read:
|
||||
record![CloudKitArticleStatus.Fields.read] = status.flag ? "1" : "0"
|
||||
case .starred:
|
||||
record![CloudKitArticleStatus.Fields.starred] = status.flag ? "1" : "0"
|
||||
case .userDeleted:
|
||||
record![CloudKitArticleStatus.Fields.userDeleted] = status.flag ? "1" : "0"
|
||||
}
|
||||
}
|
||||
|
||||
return Array(records.values)
|
||||
}
|
||||
|
||||
func makeArticleRecordsIfNecessary(_ articles: Set<Article>, completion: @escaping ((Result<[CKRecord], Error>) -> Void)) {
|
||||
let group = DispatchGroup()
|
||||
var errorOccurred = false
|
||||
var records = [CKRecord]()
|
||||
|
||||
for article in articles {
|
||||
|
||||
let statusRecordID = CKRecord.ID(recordName: article.articleID, zoneID: Self.zoneID)
|
||||
let statusRecordRef = CKRecord.Reference(recordID: statusRecordID, action: .deleteSelf)
|
||||
let predicate = NSPredicate(format: "articleStatus = %@", statusRecordRef)
|
||||
let ckQuery = CKQuery(recordType: CloudKitArticle.recordType, predicate: predicate)
|
||||
|
||||
group.enter()
|
||||
exists(ckQuery) { result in
|
||||
switch result {
|
||||
case .success(let recordFound):
|
||||
if !recordFound {
|
||||
records.append(contentsOf: self.makeArticleRecords(article))
|
||||
}
|
||||
case .failure(let error):
|
||||
errorOccurred = true
|
||||
os_log(.error, log: self.log, "Error occurred while checking for existing articles: %@", error.localizedDescription)
|
||||
}
|
||||
group.leave()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
group.notify(queue: DispatchQueue.main) {
|
||||
if errorOccurred {
|
||||
completion(.failure(CloudKitZoneError.unknown))
|
||||
} else {
|
||||
completion(.success(records))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func makeArticleRecords(_ article: Article) -> [CKRecord] {
|
||||
var records = [CKRecord]()
|
||||
|
||||
let articleRecord = CKRecord(recordType: CloudKitArticle.recordType, recordID: generateRecordID())
|
||||
|
||||
let articleStatusRecordID = CKRecord.ID(recordName: article.articleID, zoneID: Self.zoneID)
|
||||
articleRecord[CloudKitArticle.Fields.articleStatus] = CKRecord.Reference(recordID: articleStatusRecordID, action: .deleteSelf)
|
||||
articleRecord[CloudKitArticle.Fields.webFeedURL] = article.webFeed?.url
|
||||
articleRecord[CloudKitArticle.Fields.uniqueID] = article.uniqueID
|
||||
articleRecord[CloudKitArticle.Fields.title] = article.title
|
||||
articleRecord[CloudKitArticle.Fields.contentHTML] = article.contentHTML
|
||||
articleRecord[CloudKitArticle.Fields.contentText] = article.contentText
|
||||
articleRecord[CloudKitArticle.Fields.url] = article.url
|
||||
articleRecord[CloudKitArticle.Fields.externalURL] = article.externalURL
|
||||
articleRecord[CloudKitArticle.Fields.summary] = article.summary
|
||||
articleRecord[CloudKitArticle.Fields.imageURL] = article.imageURL
|
||||
articleRecord[CloudKitArticle.Fields.datePublished] = article.datePublished
|
||||
articleRecord[CloudKitArticle.Fields.dateModified] = article.dateModified
|
||||
|
||||
let encoder = JSONEncoder()
|
||||
var parsedAuthors = [String]()
|
||||
|
||||
if let authors = article.authors {
|
||||
for author in authors {
|
||||
let parsedAuthor = ParsedAuthor(name: author.name,
|
||||
url: author.url,
|
||||
avatarURL: author.avatarURL,
|
||||
emailAddress: author.emailAddress)
|
||||
if let data = try? encoder.encode(parsedAuthor), let encodedParsedAuthor = String(data: data, encoding: .utf8) {
|
||||
parsedAuthors.append(encodedParsedAuthor)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
articleRecord[CloudKitArticle.Fields.parsedAuthors] = parsedAuthors
|
||||
|
||||
records.append(articleRecord)
|
||||
return records
|
||||
}
|
||||
|
||||
|
||||
}
|
|
@ -0,0 +1,162 @@
|
|||
//
|
||||
// CloudKitArticlesZoneDelegate.swift
|
||||
// Account
|
||||
//
|
||||
// Created by Maurice Parker on 4/1/20.
|
||||
// Copyright © 2020 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import os.log
|
||||
import RSParser
|
||||
import CloudKit
|
||||
import SyncDatabase
|
||||
|
||||
class CloudKitArticlesZoneDelegate: CloudKitZoneDelegate {
|
||||
|
||||
private var log = OSLog(subsystem: Bundle.main.bundleIdentifier!, category: "CloudKit")
|
||||
|
||||
weak var account: Account?
|
||||
var database: SyncDatabase
|
||||
weak var articlesZone: CloudKitArticlesZone?
|
||||
|
||||
init(account: Account, database: SyncDatabase, articlesZone: CloudKitArticlesZone) {
|
||||
self.account = account
|
||||
self.database = database
|
||||
self.articlesZone = articlesZone
|
||||
}
|
||||
|
||||
func cloudKitDidChange(record: CKRecord) {
|
||||
|
||||
}
|
||||
|
||||
func cloudKitDidDelete(recordKey: CloudKitRecordKey) {
|
||||
// Article downloads clean up old articles and statuses
|
||||
}
|
||||
|
||||
func cloudKitDidModify(changed: [CKRecord], deleted: [CloudKitRecordKey], completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
|
||||
database.selectPendingReadStatusArticleIDs() { result in
|
||||
switch result {
|
||||
case .success(let pendingReadStatusArticleIDs):
|
||||
|
||||
self.database.selectPendingStarredStatusArticleIDs() { result in
|
||||
switch result {
|
||||
case .success(let pendingStarredStatusArticleIDs):
|
||||
|
||||
self.process(records: changed,
|
||||
pendingReadStatusArticleIDs: pendingReadStatusArticleIDs,
|
||||
pendingStarredStatusArticleIDs: pendingStarredStatusArticleIDs,
|
||||
completion: completion)
|
||||
|
||||
case .failure(let error):
|
||||
os_log(.error, log: self.log, "Error occurred geting pending starred records: %@", error.localizedDescription)
|
||||
}
|
||||
}
|
||||
case .failure(let error):
|
||||
os_log(.error, log: self.log, "Error occurred getting pending read status records: %@", error.localizedDescription)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private extension CloudKitArticlesZoneDelegate {
|
||||
|
||||
func process(records: [CKRecord], pendingReadStatusArticleIDs: Set<String>, pendingStarredStatusArticleIDs: Set<String>, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
|
||||
let receivedUnreadArticleIDs = Set(records.filter({ $0[CloudKitArticlesZone.CloudKitArticleStatus.Fields.read] == "0" }).map({ $0.externalID }))
|
||||
let receivedReadArticleIDs = Set(records.filter({ $0[CloudKitArticlesZone.CloudKitArticleStatus.Fields.read] == "1" }).map({ $0.externalID }))
|
||||
let receivedUnstarredArticleIDs = Set(records.filter({ $0[CloudKitArticlesZone.CloudKitArticleStatus.Fields.starred] == "0" }).map({ $0.externalID }))
|
||||
let receivedStarredArticleIDs = Set(records.filter({ $0[CloudKitArticlesZone.CloudKitArticleStatus.Fields.starred] == "1" }).map({ $0.externalID }))
|
||||
|
||||
let receivedStarredArticles = records.filter({ $0.recordType == CloudKitArticlesZone.CloudKitArticle.recordType })
|
||||
|
||||
let updateableUnreadArticleIDs = receivedUnreadArticleIDs.subtracting(pendingReadStatusArticleIDs)
|
||||
let updateableReadArticleIDs = receivedReadArticleIDs.subtracting(pendingReadStatusArticleIDs)
|
||||
let updateableUnstarredArticleIDs = receivedUnstarredArticleIDs.subtracting(pendingStarredStatusArticleIDs)
|
||||
let updateableStarredArticleIDs = receivedStarredArticleIDs.subtracting(pendingStarredStatusArticleIDs)
|
||||
|
||||
let group = DispatchGroup()
|
||||
|
||||
group.enter()
|
||||
account?.markAsUnread(updateableUnreadArticleIDs) { _ in
|
||||
group.leave()
|
||||
}
|
||||
|
||||
group.enter()
|
||||
account?.markAsRead(updateableReadArticleIDs) { _ in
|
||||
group.leave()
|
||||
}
|
||||
|
||||
group.enter()
|
||||
account?.markAsUnstarred(updateableUnstarredArticleIDs) { _ in
|
||||
group.leave()
|
||||
}
|
||||
|
||||
group.enter()
|
||||
account?.markAsStarred(updateableStarredArticleIDs) { _ in
|
||||
group.leave()
|
||||
}
|
||||
|
||||
for receivedStarredArticle in receivedStarredArticles {
|
||||
if let parsedItem = makeParsedItem(receivedStarredArticle) {
|
||||
group.enter()
|
||||
self.account?.update(parsedItem.feedURL, with: Set([parsedItem])) { databaseError in
|
||||
group.leave()
|
||||
if let databaseError = databaseError {
|
||||
os_log(.error, log: self.log, "Error occurred while storing starred items: %@", databaseError.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
group.notify(queue: DispatchQueue.main) {
|
||||
completion(.success(()))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
func makeParsedItem(_ articleRecord: CKRecord) -> ParsedItem? {
|
||||
var parsedAuthors = Set<ParsedAuthor>()
|
||||
|
||||
let decoder = JSONDecoder()
|
||||
|
||||
if let encodedParsedAuthors = articleRecord[CloudKitArticlesZone.CloudKitArticle.Fields.parsedAuthors] as? [String] {
|
||||
for encodedParsedAuthor in encodedParsedAuthors {
|
||||
if let data = encodedParsedAuthor.data(using: .utf8), let parsedAuthor = try? decoder.decode(ParsedAuthor.self, from: data) {
|
||||
parsedAuthors.insert(parsedAuthor)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
guard let uniqueID = articleRecord[CloudKitArticlesZone.CloudKitArticle.Fields.uniqueID] as? String,
|
||||
let webFeedURL = articleRecord[CloudKitArticlesZone.CloudKitArticle.Fields.webFeedURL] as? String else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let parsedItem = ParsedItem(syncServiceID: nil,
|
||||
uniqueID: uniqueID,
|
||||
feedURL: webFeedURL,
|
||||
url: articleRecord[CloudKitArticlesZone.CloudKitArticle.Fields.url] as? String,
|
||||
externalURL: articleRecord[CloudKitArticlesZone.CloudKitArticle.Fields.externalURL] as? String,
|
||||
title: articleRecord[CloudKitArticlesZone.CloudKitArticle.Fields.title] as? String,
|
||||
language: nil,
|
||||
contentHTML: articleRecord[CloudKitArticlesZone.CloudKitArticle.Fields.contentHTML] as? String,
|
||||
contentText: articleRecord[CloudKitArticlesZone.CloudKitArticle.Fields.contentText] as? String,
|
||||
summary: articleRecord[CloudKitArticlesZone.CloudKitArticle.Fields.summary] as? String,
|
||||
imageURL: articleRecord[CloudKitArticlesZone.CloudKitArticle.Fields.imageURL] as? String,
|
||||
bannerImageURL: articleRecord[CloudKitArticlesZone.CloudKitArticle.Fields.imageURL] as? String,
|
||||
datePublished: articleRecord[CloudKitArticlesZone.CloudKitArticle.Fields.datePublished] as? Date,
|
||||
dateModified: articleRecord[CloudKitArticlesZone.CloudKitArticle.Fields.dateModified] as? Date,
|
||||
authors: parsedAuthors,
|
||||
tags: nil,
|
||||
attachments: nil)
|
||||
|
||||
return parsedItem
|
||||
}
|
||||
|
||||
}
|
|
@ -1,18 +1,29 @@
|
|||
//
|
||||
// CKError+Extensions.swift
|
||||
// CloudKitError.swift
|
||||
// Account
|
||||
//
|
||||
// Created by Maurice Parker on 3/26/20.
|
||||
// Copyright © 2020 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
// Derived from https://github.com/caiyue1993/IceCream
|
||||
|
||||
import Foundation
|
||||
import CloudKit
|
||||
|
||||
extension CKError: LocalizedError {
|
||||
class CloudKitError: LocalizedError {
|
||||
|
||||
let error: Error
|
||||
|
||||
init(_ error: Error) {
|
||||
self.error = error
|
||||
}
|
||||
|
||||
public var errorDescription: String? {
|
||||
switch code {
|
||||
guard let ckError = error as? CKError else {
|
||||
return error.localizedDescription
|
||||
}
|
||||
|
||||
switch ckError.code {
|
||||
case .alreadyShared:
|
||||
return NSLocalizedString("Already Shared: a record or share cannot be saved because doing so would cause the same hierarchy of records to exist in multiple shares.", comment: "Known iCloud Error")
|
||||
case .assetFileModified:
|
|
@ -1,33 +0,0 @@
|
|||
//
|
||||
// CloudKitRecordConvertable.swift
|
||||
// Account
|
||||
//
|
||||
// Created by Maurice Parker on 3/21/20.
|
||||
// Copyright © 2020 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import CloudKit
|
||||
|
||||
protocol CloudKitRecordConvertible {
|
||||
static var cloudKitRecordType: String { get }
|
||||
static var cloudKitZoneID: CKRecordZone.ID { get }
|
||||
|
||||
var cloudKitPrimaryKey: String { get }
|
||||
var recordID: CKRecord.ID { get }
|
||||
var cloudKitRecord: CKRecord { get }
|
||||
|
||||
func assignCloudKitPrimaryKeyIfNecessary()
|
||||
}
|
||||
|
||||
extension CloudKitRecordConvertible {
|
||||
|
||||
public static var cloudKitRecordType: String {
|
||||
return String(describing: self)
|
||||
}
|
||||
|
||||
public var recordID: CKRecord.ID {
|
||||
return CKRecord.ID(recordName: cloudKitPrimaryKey, zoneID: Self.cloudKitZoneID)
|
||||
}
|
||||
|
||||
}
|
|
@ -9,17 +9,17 @@
|
|||
import Foundation
|
||||
import CloudKit
|
||||
|
||||
enum CloudKitResult {
|
||||
enum CloudKitZoneResult {
|
||||
case success
|
||||
case retry(afterSeconds: Double)
|
||||
case chunk
|
||||
case limitExceeded
|
||||
case changeTokenExpired
|
||||
case partialFailure
|
||||
case partialFailure(errors: [CKRecord.ID: CKError])
|
||||
case serverRecordChanged
|
||||
case noZone
|
||||
case failure(error: Error)
|
||||
|
||||
static func resolve(_ error: Error?) -> CloudKitResult {
|
||||
static func resolve(_ error: Error?) -> CloudKitZoneResult {
|
||||
|
||||
guard error != nil else { return .success }
|
||||
|
||||
|
@ -39,11 +39,17 @@ enum CloudKitResult {
|
|||
case .serverRecordChanged:
|
||||
return .serverRecordChanged
|
||||
case .partialFailure:
|
||||
return .partialFailure
|
||||
case .limitExceeded:
|
||||
return .chunk
|
||||
case .zoneNotFound, .userDeletedZone:
|
||||
if let partialErrors = ckError.userInfo[CKPartialErrorsByItemIDKey] as? [CKRecord.ID: CKError] {
|
||||
if anyZoneErrors(partialErrors) {
|
||||
return .noZone
|
||||
} else {
|
||||
return .partialFailure(errors: partialErrors)
|
||||
}
|
||||
} else {
|
||||
return .failure(error: error!)
|
||||
}
|
||||
case .limitExceeded:
|
||||
return .limitExceeded
|
||||
default:
|
||||
return .failure(error: error!)
|
||||
}
|
||||
|
@ -51,3 +57,11 @@ enum CloudKitResult {
|
|||
}
|
||||
|
||||
}
|
||||
|
||||
private extension CloudKitZoneResult {
|
||||
|
||||
static func anyZoneErrors(_ errors: [CKRecord.ID: CKError]) -> Bool {
|
||||
return errors.values.contains(where: { $0.code == .zoneNotFound || $0.code == .userDeletedZone } )
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -7,35 +7,575 @@
|
|||
//
|
||||
|
||||
import CloudKit
|
||||
import os.log
|
||||
import RSWeb
|
||||
|
||||
public enum CloudKitZoneError: Error {
|
||||
enum CloudKitZoneError: LocalizedError {
|
||||
case userDeletedZone
|
||||
case invalidParameter
|
||||
case unknown
|
||||
|
||||
var errorDescription: String? {
|
||||
if case .userDeletedZone = self {
|
||||
return NSLocalizedString("The iCloud data was deleted. Please delete the NetNewsWire iCloud account and add it again to continue using NetNewsWire's iCloud support.", comment: "User deleted zone.")
|
||||
} else {
|
||||
return NSLocalizedString("An unexpected CloudKit error occurred.", comment: "An unexpected CloudKit error occurred.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public protocol CloudKitZone: class {
|
||||
protocol CloudKitZoneDelegate: class {
|
||||
func cloudKitDidChange(record: CKRecord);
|
||||
func cloudKitDidDelete(recordKey: CloudKitRecordKey)
|
||||
func cloudKitDidModify(changed: [CKRecord], deleted: [CloudKitRecordKey], completion: @escaping (Result<Void, Error>) -> Void);
|
||||
}
|
||||
|
||||
typealias CloudKitRecordKey = (recordType: CKRecord.RecordType, recordID: CKRecord.ID)
|
||||
|
||||
protocol CloudKitZone: class {
|
||||
|
||||
static var zoneID: CKRecordZone.ID { get }
|
||||
|
||||
var container: CKContainer { get }
|
||||
var database: CKDatabase { get }
|
||||
var log: OSLog { get }
|
||||
|
||||
// func prepare()
|
||||
var container: CKContainer? { get }
|
||||
var database: CKDatabase? { get }
|
||||
var delegate: CloudKitZoneDelegate? { get set }
|
||||
|
||||
// func fetchChangesInDatabase(_ callback: ((Error?) -> Void)?)
|
||||
/// Reset the change token used to determine what point in time we are doing changes fetches
|
||||
func resetChangeToken()
|
||||
|
||||
/// The CloudKit Best Practice is out of date, now use this:
|
||||
/// https://developer.apple.com/documentation/cloudkit/ckoperation
|
||||
/// Which problem does this func solve? E.g.:
|
||||
/// 1.(Offline) You make a local change, involve a operation
|
||||
/// 2. App exits or ejected by user
|
||||
/// 3. Back to app again
|
||||
/// The operation resumes! All works like a magic!
|
||||
func resumeLongLivedOperationIfPossible()
|
||||
/// Generates a new CKRecord.ID using a UUID for the record's name
|
||||
func generateRecordID() -> CKRecord.ID
|
||||
|
||||
/// Subscribe to changes at a zone level
|
||||
func subscribeToZoneChanges()
|
||||
|
||||
/// Process a remove notification
|
||||
func receiveRemoteNotification(userInfo: [AnyHashable : Any], completion: @escaping () -> Void)
|
||||
|
||||
}
|
||||
|
||||
extension CloudKitZone {
|
||||
|
||||
/// Reset the change token used to determine what point in time we are doing changes fetches
|
||||
func resetChangeToken() {
|
||||
changeToken = nil
|
||||
}
|
||||
|
||||
func generateRecordID() -> CKRecord.ID {
|
||||
return CKRecord.ID(recordName: UUID().uuidString, zoneID: Self.zoneID)
|
||||
}
|
||||
|
||||
func retryIfPossible(after: Double, block: @escaping () -> ()) {
|
||||
let delayTime = DispatchTime.now() + after
|
||||
DispatchQueue.main.asyncAfter(deadline: delayTime, execute: {
|
||||
block()
|
||||
})
|
||||
}
|
||||
|
||||
func receiveRemoteNotification(userInfo: [AnyHashable : Any], completion: @escaping () -> Void) {
|
||||
let note = CKRecordZoneNotification(fromRemoteNotificationDictionary: userInfo)
|
||||
guard note?.recordZoneID?.zoneName == Self.zoneID.zoneName else {
|
||||
completion()
|
||||
return
|
||||
}
|
||||
|
||||
fetchChangesInZone() { result in
|
||||
if case .failure(let error) = result {
|
||||
os_log(.error, log: self.log, "%@ zone remote notification fetch error: %@", Self.zoneID.zoneName, error.localizedDescription)
|
||||
}
|
||||
completion()
|
||||
}
|
||||
}
|
||||
|
||||
func createZoneRecord(completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
guard let database = database else {
|
||||
completion(.failure(CloudKitZoneError.unknown))
|
||||
return
|
||||
}
|
||||
|
||||
database.save(CKRecordZone(zoneID: Self.zoneID)) { (recordZone, error) in
|
||||
if let error = error {
|
||||
DispatchQueue.main.async {
|
||||
completion(.failure(CloudKitError(error)))
|
||||
}
|
||||
} else {
|
||||
DispatchQueue.main.async {
|
||||
completion(.success(()))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func subscribeToZoneChanges() {
|
||||
let subscription = CKRecordZoneSubscription(zoneID: Self.zoneID)
|
||||
|
||||
let info = CKSubscription.NotificationInfo()
|
||||
info.shouldSendContentAvailable = true
|
||||
subscription.notificationInfo = info
|
||||
|
||||
save(subscription) { result in
|
||||
if case .failure(let error) = result {
|
||||
os_log(.error, log: self.log, "%@ zone subscribe to changes error: %@", Self.zoneID.zoneName, error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Checks to see if the record described in the query exists by retrieving only the testField parameter field.
|
||||
func exists(_ query: CKQuery, completion: @escaping (Result<Bool, Error>) -> Void) {
|
||||
var recordFound = false
|
||||
let op = CKQueryOperation(query: query)
|
||||
op.desiredKeys = ["creationDate"]
|
||||
|
||||
op.recordFetchedBlock = { record in
|
||||
recordFound = true
|
||||
}
|
||||
|
||||
op.queryCompletionBlock = { [weak self] (_, error) in
|
||||
switch CloudKitZoneResult.resolve(error) {
|
||||
case .success:
|
||||
DispatchQueue.main.async {
|
||||
completion(.success(recordFound))
|
||||
}
|
||||
case .zoneNotFound:
|
||||
self?.createZoneRecord() { result in
|
||||
switch result {
|
||||
case .success:
|
||||
self?.exists(query, completion: completion)
|
||||
case .failure(let error):
|
||||
DispatchQueue.main.async {
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
case .retry(let timeToWait):
|
||||
self?.retryIfPossible(after: timeToWait) {
|
||||
self?.exists(query, completion: completion)
|
||||
}
|
||||
case .userDeletedZone:
|
||||
DispatchQueue.main.async {
|
||||
completion(.failure(CloudKitZoneError.userDeletedZone))
|
||||
}
|
||||
default:
|
||||
DispatchQueue.main.async {
|
||||
completion(.failure(CloudKitError(error!)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
database?.add(op)
|
||||
}
|
||||
|
||||
/// Issue a CKQuery and return the resulting CKRecords.s
|
||||
func query(_ query: CKQuery, completion: @escaping (Result<[CKRecord], Error>) -> Void) {
|
||||
guard let database = database else {
|
||||
completion(.failure(CloudKitZoneError.unknown))
|
||||
return
|
||||
}
|
||||
|
||||
database.perform(query, inZoneWith: Self.zoneID) { [weak self] records, error in
|
||||
switch CloudKitZoneResult.resolve(error) {
|
||||
case .success:
|
||||
DispatchQueue.main.async {
|
||||
if let records = records {
|
||||
completion(.success(records))
|
||||
} else {
|
||||
completion(.failure(CloudKitZoneError.unknown))
|
||||
}
|
||||
}
|
||||
case .zoneNotFound:
|
||||
self?.createZoneRecord() { result in
|
||||
switch result {
|
||||
case .success:
|
||||
self?.query(query, completion: completion)
|
||||
case .failure(let error):
|
||||
DispatchQueue.main.async {
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
case .retry(let timeToWait):
|
||||
self?.retryIfPossible(after: timeToWait) {
|
||||
self?.query(query, completion: completion)
|
||||
}
|
||||
case .userDeletedZone:
|
||||
DispatchQueue.main.async {
|
||||
completion(.failure(CloudKitZoneError.userDeletedZone))
|
||||
}
|
||||
default:
|
||||
DispatchQueue.main.async {
|
||||
completion(.failure(CloudKitError(error!)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch a CKRecord by using its externalID
|
||||
func fetch(externalID: String?, completion: @escaping (Result<CKRecord, Error>) -> Void) {
|
||||
guard let externalID = externalID else {
|
||||
completion(.failure(CloudKitZoneError.invalidParameter))
|
||||
return
|
||||
}
|
||||
|
||||
let recordID = CKRecord.ID(recordName: externalID, zoneID: Self.zoneID)
|
||||
|
||||
database?.fetch(withRecordID: recordID) { [weak self] record, error in
|
||||
switch CloudKitZoneResult.resolve(error) {
|
||||
case .success:
|
||||
DispatchQueue.main.async {
|
||||
if let record = record {
|
||||
completion(.success(record))
|
||||
} else {
|
||||
completion(.failure(CloudKitZoneError.unknown))
|
||||
}
|
||||
}
|
||||
case .zoneNotFound:
|
||||
self?.createZoneRecord() { result in
|
||||
switch result {
|
||||
case .success:
|
||||
self?.fetch(externalID: externalID, completion: completion)
|
||||
case .failure(let error):
|
||||
DispatchQueue.main.async {
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
case .retry(let timeToWait):
|
||||
self?.retryIfPossible(after: timeToWait) {
|
||||
self?.fetch(externalID: externalID, completion: completion)
|
||||
}
|
||||
case .userDeletedZone:
|
||||
DispatchQueue.main.async {
|
||||
completion(.failure(CloudKitZoneError.userDeletedZone))
|
||||
}
|
||||
default:
|
||||
DispatchQueue.main.async {
|
||||
completion(.failure(CloudKitError(error!)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Save the CKRecord
|
||||
func save(_ record: CKRecord, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
modify(recordsToSave: [record], recordIDsToDelete: [], completion: completion)
|
||||
}
|
||||
|
||||
/// Save the CKRecords
|
||||
func save(_ records: [CKRecord], completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
modify(recordsToSave: records, recordIDsToDelete: [], completion: completion)
|
||||
}
|
||||
|
||||
/// Saves or modifies the records as long as they are unchanged relative to the local version
|
||||
func saveIfNew(_ records: [CKRecord], completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
let op = CKModifyRecordsOperation(recordsToSave: records, recordIDsToDelete: [CKRecord.ID]())
|
||||
op.savePolicy = .ifServerRecordUnchanged
|
||||
op.isAtomic = false
|
||||
|
||||
op.modifyRecordsCompletionBlock = { [weak self] (_, _, error) in
|
||||
|
||||
guard let self = self else { return }
|
||||
|
||||
switch CloudKitZoneResult.resolve(error) {
|
||||
case .success:
|
||||
DispatchQueue.main.async {
|
||||
completion(.success(()))
|
||||
}
|
||||
case .zoneNotFound:
|
||||
self.createZoneRecord() { result in
|
||||
switch result {
|
||||
case .success:
|
||||
self.saveIfNew(records, completion: completion)
|
||||
case .failure(let error):
|
||||
DispatchQueue.main.async {
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
case .userDeletedZone:
|
||||
DispatchQueue.main.async {
|
||||
completion(.failure(CloudKitZoneError.userDeletedZone))
|
||||
}
|
||||
case .retry(let timeToWait):
|
||||
self.retryIfPossible(after: timeToWait) {
|
||||
self.saveIfNew(records, completion: completion)
|
||||
}
|
||||
case .limitExceeded:
|
||||
|
||||
let chunkedRecords = records.chunked(into: 300)
|
||||
|
||||
let group = DispatchGroup()
|
||||
var errorOccurred = false
|
||||
|
||||
for chunk in chunkedRecords {
|
||||
group.enter()
|
||||
self.saveIfNew(chunk) { result in
|
||||
if case .failure(let error) = result {
|
||||
os_log(.error, log: self.log, "%@ zone modify records error: %@", Self.zoneID.zoneName, error.localizedDescription)
|
||||
errorOccurred = true
|
||||
}
|
||||
group.leave()
|
||||
}
|
||||
}
|
||||
|
||||
group.notify(queue: DispatchQueue.main) {
|
||||
if errorOccurred {
|
||||
completion(.failure(CloudKitZoneError.unknown))
|
||||
} else {
|
||||
completion(.success(()))
|
||||
}
|
||||
}
|
||||
|
||||
default:
|
||||
DispatchQueue.main.async {
|
||||
completion(.failure(CloudKitError(error!)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
database?.add(op)
|
||||
}
|
||||
|
||||
/// Save the CKSubscription
|
||||
func save(_ subscription: CKSubscription, completion: @escaping (Result<CKSubscription, Error>) -> Void) {
|
||||
database?.save(subscription) { savedSubscription, error in
|
||||
switch CloudKitZoneResult.resolve(error) {
|
||||
case .success:
|
||||
DispatchQueue.main.async {
|
||||
completion(.success((savedSubscription!)))
|
||||
}
|
||||
case .zoneNotFound:
|
||||
self.createZoneRecord() { result in
|
||||
switch result {
|
||||
case .success:
|
||||
self.save(subscription, completion: completion)
|
||||
case .failure(let error):
|
||||
DispatchQueue.main.async {
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
case .retry(let timeToWait):
|
||||
self.retryIfPossible(after: timeToWait) {
|
||||
self.save(subscription, completion: completion)
|
||||
}
|
||||
default:
|
||||
DispatchQueue.main.async {
|
||||
completion(.failure(CloudKitError(error!)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete a CKRecord using its recordID
|
||||
func delete(recordID: CKRecord.ID, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
modify(recordsToSave: [], recordIDsToDelete: [recordID], completion: completion)
|
||||
}
|
||||
|
||||
/// Delete a CKRecord using its externalID
|
||||
func delete(externalID: String?, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
guard let externalID = externalID else {
|
||||
completion(.failure(CloudKitZoneError.invalidParameter))
|
||||
return
|
||||
}
|
||||
|
||||
let recordID = CKRecord.ID(recordName: externalID, zoneID: Self.zoneID)
|
||||
modify(recordsToSave: [], recordIDsToDelete: [recordID], completion: completion)
|
||||
}
|
||||
|
||||
/// Delete a CKSubscription
|
||||
func delete(subscriptionID: String, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
database?.delete(withSubscriptionID: subscriptionID) { _, error in
|
||||
switch CloudKitZoneResult.resolve(error) {
|
||||
case .success:
|
||||
DispatchQueue.main.async {
|
||||
completion(.success(()))
|
||||
}
|
||||
case .retry(let timeToWait):
|
||||
self.retryIfPossible(after: timeToWait) {
|
||||
self.delete(subscriptionID: subscriptionID, completion: completion)
|
||||
}
|
||||
default:
|
||||
DispatchQueue.main.async {
|
||||
completion(.failure(CloudKitError(error!)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Modify and delete the supplied CKRecords and CKRecord.IDs
|
||||
func modify(recordsToSave: [CKRecord], recordIDsToDelete: [CKRecord.ID], completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
let op = CKModifyRecordsOperation(recordsToSave: recordsToSave, recordIDsToDelete: recordIDsToDelete)
|
||||
op.savePolicy = .changedKeys
|
||||
op.isAtomic = true
|
||||
|
||||
op.modifyRecordsCompletionBlock = { [weak self] (_, _, error) in
|
||||
|
||||
guard let self = self else { return }
|
||||
|
||||
switch CloudKitZoneResult.resolve(error) {
|
||||
case .success:
|
||||
DispatchQueue.main.async {
|
||||
completion(.success(()))
|
||||
}
|
||||
case .zoneNotFound:
|
||||
self.createZoneRecord() { result in
|
||||
switch result {
|
||||
case .success:
|
||||
self.modify(recordsToSave: recordsToSave, recordIDsToDelete: recordIDsToDelete, completion: completion)
|
||||
case .failure(let error):
|
||||
DispatchQueue.main.async {
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
case .userDeletedZone:
|
||||
DispatchQueue.main.async {
|
||||
completion(.failure(CloudKitZoneError.userDeletedZone))
|
||||
}
|
||||
case .retry(let timeToWait):
|
||||
self.retryIfPossible(after: timeToWait) {
|
||||
self.modify(recordsToSave: recordsToSave, recordIDsToDelete: recordIDsToDelete, completion: completion)
|
||||
}
|
||||
case .limitExceeded:
|
||||
let chunkedRecords = recordsToSave.chunked(into: 300)
|
||||
|
||||
let group = DispatchGroup()
|
||||
var errorOccurred = false
|
||||
|
||||
for chunk in chunkedRecords {
|
||||
group.enter()
|
||||
self.modify(recordsToSave: chunk, recordIDsToDelete: recordIDsToDelete) { result in
|
||||
if case .failure(let error) = result {
|
||||
os_log(.error, log: self.log, "%@ zone modify records error: %@", Self.zoneID.zoneName, error.localizedDescription)
|
||||
errorOccurred = true
|
||||
}
|
||||
group.leave()
|
||||
}
|
||||
}
|
||||
|
||||
group.notify(queue: DispatchQueue.main) {
|
||||
if errorOccurred {
|
||||
completion(.failure(CloudKitZoneError.unknown))
|
||||
} else {
|
||||
completion(.success(()))
|
||||
}
|
||||
}
|
||||
|
||||
default:
|
||||
DispatchQueue.main.async {
|
||||
completion(.failure(CloudKitError(error!)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
database?.add(op)
|
||||
}
|
||||
|
||||
/// Fetch all the changes in the CKZone since the last time we checked
|
||||
func fetchChangesInZone(completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
|
||||
var changedRecords = [CKRecord]()
|
||||
var deletedRecordKeys = [CloudKitRecordKey]()
|
||||
|
||||
let zoneConfig = CKFetchRecordZoneChangesOperation.ZoneConfiguration()
|
||||
zoneConfig.previousServerChangeToken = changeToken
|
||||
let op = CKFetchRecordZoneChangesOperation(recordZoneIDs: [Self.zoneID], configurationsByRecordZoneID: [Self.zoneID: zoneConfig])
|
||||
op.fetchAllChanges = true
|
||||
|
||||
op.recordZoneChangeTokensUpdatedBlock = { [weak self] zoneID, token, _ in
|
||||
guard let self = self else { return }
|
||||
|
||||
DispatchQueue.main.async {
|
||||
self.changeToken = token
|
||||
}
|
||||
}
|
||||
|
||||
op.recordChangedBlock = { [weak self] record in
|
||||
guard let self = self else { return }
|
||||
|
||||
changedRecords.append(record)
|
||||
DispatchQueue.main.async {
|
||||
self.delegate?.cloudKitDidChange(record: record)
|
||||
}
|
||||
}
|
||||
|
||||
op.recordWithIDWasDeletedBlock = { [weak self] recordID, recordType in
|
||||
guard let self = self else { return }
|
||||
|
||||
let recordKey = CloudKitRecordKey(recordType: recordType, recordID: recordID)
|
||||
deletedRecordKeys.append(recordKey)
|
||||
|
||||
DispatchQueue.main.async {
|
||||
self.delegate?.cloudKitDidDelete(recordKey: recordKey)
|
||||
}
|
||||
}
|
||||
|
||||
op.recordZoneFetchCompletionBlock = { [weak self] zoneID ,token, _, _, error in
|
||||
guard let self = self else { return }
|
||||
|
||||
switch CloudKitZoneResult.resolve(error) {
|
||||
case .success:
|
||||
DispatchQueue.main.async {
|
||||
self.changeToken = token
|
||||
}
|
||||
case .retry(let timeToWait):
|
||||
self.retryIfPossible(after: timeToWait) {
|
||||
self.fetchChangesInZone(completion: completion)
|
||||
}
|
||||
default:
|
||||
os_log(.error, log: self.log, "%@ zone fetch changes error: %@", zoneID.zoneName, error?.localizedDescription ?? "Unknown")
|
||||
}
|
||||
}
|
||||
|
||||
op.fetchRecordZoneChangesCompletionBlock = { [weak self] error in
|
||||
guard let self = self else { return }
|
||||
|
||||
switch CloudKitZoneResult.resolve(error) {
|
||||
case .success:
|
||||
DispatchQueue.main.async {
|
||||
self.delegate?.cloudKitDidModify(changed: changedRecords, deleted: deletedRecordKeys, completion: completion)
|
||||
}
|
||||
case .zoneNotFound:
|
||||
self.createZoneRecord() { result in
|
||||
switch result {
|
||||
case .success:
|
||||
self.fetchChangesInZone(completion: completion)
|
||||
case .failure(let error):
|
||||
DispatchQueue.main.async {
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
case .userDeletedZone:
|
||||
DispatchQueue.main.async {
|
||||
completion(.failure(CloudKitZoneError.userDeletedZone))
|
||||
}
|
||||
case .retry(let timeToWait):
|
||||
self.retryIfPossible(after: timeToWait) {
|
||||
self.fetchChangesInZone(completion: completion)
|
||||
}
|
||||
case .changeTokenExpired:
|
||||
DispatchQueue.main.async {
|
||||
self.changeToken = nil
|
||||
self.fetchChangesInZone(completion: completion)
|
||||
}
|
||||
default:
|
||||
DispatchQueue.main.async {
|
||||
completion(.failure(CloudKitError(error!)))
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
database?.add(op)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private extension CloudKitZone {
|
||||
|
||||
var changeTokenKey: String {
|
||||
return "cloudkit.server.token.\(Self.zoneID.zoneName)"
|
||||
}
|
||||
|
@ -60,138 +600,4 @@ extension CloudKitZone {
|
|||
return config
|
||||
}
|
||||
|
||||
func generateRecordID() -> CKRecord.ID {
|
||||
return CKRecord.ID(recordName: UUID().uuidString, zoneID: Self.zoneID)
|
||||
}
|
||||
|
||||
func createZoneRecord(completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
database.save(CKRecordZone(zoneID: Self.zoneID)) { (recordZone, error) in
|
||||
if let error = error {
|
||||
DispatchQueue.main.async {
|
||||
completion(.failure(error))
|
||||
}
|
||||
} else {
|
||||
DispatchQueue.main.async {
|
||||
completion(.success(()))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// func prepare() {
|
||||
// syncObjects.forEach {
|
||||
// $0.pipeToEngine = { [weak self] recordsToStore, recordIDsToDelete in
|
||||
// guard let self = self else { return }
|
||||
// self.syncRecordsToCloudKit(recordsToStore: recordsToStore, recordIDsToDelete: recordIDsToDelete)
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
func resumeLongLivedOperationIfPossible() {
|
||||
container.fetchAllLongLivedOperationIDs { [weak self]( opeIDs, error) in
|
||||
guard let self = self, error == nil, let ids = opeIDs else { return }
|
||||
for id in ids {
|
||||
self.container.fetchLongLivedOperation(withID: id, completionHandler: { [weak self](ope, error) in
|
||||
guard let self = self, error == nil else { return }
|
||||
if let modifyOp = ope as? CKModifyRecordsOperation {
|
||||
modifyOp.modifyRecordsCompletionBlock = { (_,_,_) in
|
||||
print("Resume modify records success!")
|
||||
}
|
||||
self.container.add(modifyOp)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// func startObservingRemoteChanges() {
|
||||
// NotificationCenter.default.addObserver(forName: Notifications.cloudKitDataDidChangeRemotely.name, object: nil, queue: nil, using: { [weak self](_) in
|
||||
// guard let self = self else { return }
|
||||
// DispatchQueue.global(qos: .utility).async {
|
||||
// self.fetchChangesInDatabase(nil)
|
||||
// }
|
||||
// })
|
||||
// }
|
||||
|
||||
public func save(record: CKRecord, completion: @escaping (Result<String, Error>) -> Void) {
|
||||
database.save(record) {(savedRecord, error) in
|
||||
|
||||
switch CloudKitResult.resolve(error) {
|
||||
|
||||
case .success:
|
||||
DispatchQueue.main.async {
|
||||
if let savedRecord = savedRecord {
|
||||
completion(.success(savedRecord.recordID.recordName))
|
||||
} else {
|
||||
completion(.failure(CloudKitZoneError.unknown))
|
||||
}
|
||||
}
|
||||
|
||||
case .retry(let timeToWait):
|
||||
self.retryOperationIfPossible(retryAfter: timeToWait) {
|
||||
self.save(record: record, completion: completion)
|
||||
}
|
||||
|
||||
default:
|
||||
return
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// Sync local data to CloudKit
|
||||
/// For more about the savePolicy: https://developer.apple.com/documentation/cloudkit/ckrecordsavepolicy
|
||||
public func syncRecordsToCloudKit(recordsToStore: [CKRecord], recordIDsToDelete: [CKRecord.ID], completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
let op = CKModifyRecordsOperation(recordsToSave: recordsToStore, recordIDsToDelete: recordIDsToDelete)
|
||||
|
||||
let config = CKOperation.Configuration()
|
||||
config.isLongLived = true
|
||||
op.configuration = config
|
||||
|
||||
// We use .changedKeys savePolicy to do unlocked changes here cause my app is contentious and off-line first
|
||||
// Apple suggests using .ifServerRecordUnchanged save policy
|
||||
// For more, see Advanced CloudKit(https://developer.apple.com/videos/play/wwdc2014/231/)
|
||||
op.savePolicy = .changedKeys
|
||||
|
||||
// To avoid CKError.partialFailure, make the operation atomic (if one record fails to get modified, they all fail)
|
||||
// If you want to handle partial failures, set .isAtomic to false and implement CKOperationResultType .fail(reason: .partialFailure) where appropriate
|
||||
op.isAtomic = true
|
||||
|
||||
op.modifyRecordsCompletionBlock = { [weak self] (_, _, error) in
|
||||
|
||||
guard let self = self else { return }
|
||||
|
||||
switch CloudKitResult.resolve(error) {
|
||||
case .success:
|
||||
DispatchQueue.main.async {
|
||||
completion(.success(()))
|
||||
}
|
||||
case .retry(let timeToWait):
|
||||
self.retryOperationIfPossible(retryAfter: timeToWait) {
|
||||
self.syncRecordsToCloudKit(recordsToStore: recordsToStore, recordIDsToDelete: recordIDsToDelete, completion: completion)
|
||||
}
|
||||
case .chunk:
|
||||
/// CloudKit says maximum number of items in a single request is 400.
|
||||
/// So I think 300 should be fine by them.
|
||||
let chunkedRecords = recordsToStore.chunked(into: 300)
|
||||
for chunk in chunkedRecords {
|
||||
self.syncRecordsToCloudKit(recordsToStore: chunk, recordIDsToDelete: recordIDsToDelete, completion: completion)
|
||||
}
|
||||
default:
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
database.add(op)
|
||||
}
|
||||
|
||||
func retryOperationIfPossible(retryAfter: Double, block: @escaping () -> ()) {
|
||||
let delayTime = DispatchTime.now() + retryAfter
|
||||
DispatchQueue.main.asyncAfter(deadline: delayTime, execute: {
|
||||
block()
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,81 @@
|
|||
//
|
||||
// CloudKitResult.swift
|
||||
// Account
|
||||
//
|
||||
// Created by Maurice Parker on 3/26/20.
|
||||
// Copyright © 2020 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import CloudKit
|
||||
|
||||
enum CloudKitZoneResult {
|
||||
case success
|
||||
case retry(afterSeconds: Double)
|
||||
case limitExceeded
|
||||
case changeTokenExpired
|
||||
case partialFailure(errors: [AnyHashable: CKError])
|
||||
case serverRecordChanged
|
||||
case zoneNotFound
|
||||
case userDeletedZone
|
||||
case failure(error: Error)
|
||||
|
||||
static func resolve(_ error: Error?) -> CloudKitZoneResult {
|
||||
|
||||
guard error != nil else { return .success }
|
||||
|
||||
guard let ckError = error as? CKError else {
|
||||
return .failure(error: error!)
|
||||
}
|
||||
|
||||
switch ckError.code {
|
||||
case .serviceUnavailable, .requestRateLimited, .zoneBusy:
|
||||
if let retry = ckError.userInfo[CKErrorRetryAfterKey] as? Double {
|
||||
return .retry(afterSeconds: retry)
|
||||
} else {
|
||||
return .failure(error: CloudKitError(ckError))
|
||||
}
|
||||
case .zoneNotFound:
|
||||
return .zoneNotFound
|
||||
case .userDeletedZone:
|
||||
return .userDeletedZone
|
||||
case .changeTokenExpired:
|
||||
return .changeTokenExpired
|
||||
case .serverRecordChanged:
|
||||
return .serverRecordChanged
|
||||
case .partialFailure:
|
||||
if let partialErrors = ckError.userInfo[CKPartialErrorsByItemIDKey] as? [AnyHashable: CKError] {
|
||||
if let zoneResult = anyRequestErrors(partialErrors) {
|
||||
return zoneResult
|
||||
} else {
|
||||
return .partialFailure(errors: partialErrors)
|
||||
}
|
||||
} else {
|
||||
return .failure(error: CloudKitError(ckError))
|
||||
}
|
||||
case .limitExceeded:
|
||||
return .limitExceeded
|
||||
default:
|
||||
return .failure(error: CloudKitError(ckError))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private extension CloudKitZoneResult {
|
||||
|
||||
static func anyRequestErrors(_ errors: [AnyHashable: CKError]) -> CloudKitZoneResult? {
|
||||
if errors.values.contains(where: { $0.code == .changeTokenExpired } ) {
|
||||
return .changeTokenExpired
|
||||
}
|
||||
if errors.values.contains(where: { $0.code == .zoneNotFound } ) {
|
||||
return .zoneNotFound
|
||||
}
|
||||
if errors.values.contains(where: { $0.code == .userDeletedZone } ) {
|
||||
return .userDeletedZone
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
}
|
|
@ -1,49 +0,0 @@
|
|||
//
|
||||
// Folder+CloudKit.swift
|
||||
// Account
|
||||
//
|
||||
// Created by Maurice Parker on 3/21/20.
|
||||
// Copyright © 2020 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import CloudKit
|
||||
|
||||
extension Folder: CloudKitRecordConvertible {
|
||||
|
||||
enum CloudKitKey: String {
|
||||
case name
|
||||
}
|
||||
|
||||
static var cloudKitZoneID: CKRecordZone.ID {
|
||||
return CloudKitAccountZone.zoneID
|
||||
}
|
||||
|
||||
var cloudKitPrimaryKey: String {
|
||||
return externalID!
|
||||
}
|
||||
|
||||
var cloudKitRecord: CKRecord {
|
||||
let record = CKRecord(recordType: Self.cloudKitRecordType)
|
||||
record[.name] = name
|
||||
return record
|
||||
}
|
||||
|
||||
func assignCloudKitPrimaryKeyIfNecessary() {
|
||||
if externalID == nil {
|
||||
externalID = UUID().uuidString
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension CKRecord {
|
||||
subscript(key: Folder.CloudKitKey) -> Any? {
|
||||
get {
|
||||
return self[key.rawValue]
|
||||
}
|
||||
set {
|
||||
self[key.rawValue] = newValue as? CKRecordValue
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,51 +0,0 @@
|
|||
//
|
||||
// WebFeed+CloudKit.swift
|
||||
// Account
|
||||
//
|
||||
// Created by Maurice Parker on 3/21/20.
|
||||
// Copyright © 2020 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import CloudKit
|
||||
|
||||
extension WebFeed: CloudKitRecordConvertible {
|
||||
|
||||
enum CloudKitKey: String {
|
||||
case url
|
||||
case editedName
|
||||
}
|
||||
|
||||
static var cloudKitZoneID: CKRecordZone.ID {
|
||||
return CloudKitAccountZone.zoneID
|
||||
}
|
||||
|
||||
var cloudKitPrimaryKey: String {
|
||||
return externalID!
|
||||
}
|
||||
|
||||
var cloudKitRecord: CKRecord {
|
||||
let record = CKRecord(recordType: Self.cloudKitRecordType)
|
||||
record[.url] = url
|
||||
record[.editedName] = editedName
|
||||
return record
|
||||
}
|
||||
|
||||
func assignCloudKitPrimaryKeyIfNecessary() {
|
||||
if externalID == nil {
|
||||
externalID = UUID().uuidString
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension CKRecord {
|
||||
subscript(key: WebFeed.CloudKitKey) -> Any? {
|
||||
get {
|
||||
return self[key.rawValue]
|
||||
}
|
||||
set {
|
||||
self[key.rawValue] = newValue as? CKRecordValue
|
||||
}
|
||||
}
|
||||
}
|
|
@ -21,6 +21,7 @@ public protocol Container: class, ContainerIdentifiable {
|
|||
var account: Account? { get }
|
||||
var topLevelWebFeeds: Set<WebFeed> { get set }
|
||||
var folders: Set<Folder>? { get set }
|
||||
var externalID: String? { get set }
|
||||
|
||||
func hasAtLeastOneWebFeed() -> Bool
|
||||
func objectIsChild(_ object: AnyObject) -> Bool
|
||||
|
@ -38,6 +39,7 @@ public protocol Container: class, ContainerIdentifiable {
|
|||
func hasWebFeed(withURL url: String) -> Bool
|
||||
func existingWebFeed(withWebFeedID: String) -> WebFeed?
|
||||
func existingWebFeed(withURL url: String) -> WebFeed?
|
||||
func existingWebFeed(withExternalID externalID: String) -> WebFeed?
|
||||
func existingFolder(with name: String) -> Folder?
|
||||
func existingFolder(withID: Int) -> Folder?
|
||||
|
||||
|
@ -117,6 +119,15 @@ public extension Container {
|
|||
return nil
|
||||
}
|
||||
|
||||
func existingWebFeed(withExternalID externalID: String) -> WebFeed? {
|
||||
for feed in flattenedWebFeeds() {
|
||||
if feed.externalID == externalID {
|
||||
return feed
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func existingFolder(with name: String) -> Folder? {
|
||||
guard let folders = folders else {
|
||||
return nil
|
||||
|
|
|
@ -60,6 +60,10 @@ final class FeedWranglerAccountDelegate: AccountDelegate {
|
|||
caller.logout() { _ in }
|
||||
}
|
||||
|
||||
func receiveRemoteNotification(for account: Account, userInfo: [AnyHashable : Any], completion: @escaping () -> Void) {
|
||||
completion()
|
||||
}
|
||||
|
||||
func refreshAll(for account: Account, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
refreshProgress.addToNumberOfTasksAndRemaining(6)
|
||||
|
||||
|
@ -279,7 +283,7 @@ final class FeedWranglerAccountDelegate: AccountDelegate {
|
|||
fatalError()
|
||||
}
|
||||
|
||||
func addFolder(for account: Account, name: String, completion: @escaping (Result<Folder, Error>) -> Void) {
|
||||
func createFolder(for account: Account, name: String, completion: @escaping (Result<Folder, Error>) -> Void) {
|
||||
fatalError()
|
||||
}
|
||||
|
||||
|
@ -511,7 +515,7 @@ private extension FeedWranglerAccountDelegate {
|
|||
let parsedItems = feedItems.map { (item: FeedWranglerFeedItem) -> ParsedItem in
|
||||
let itemID = String(item.feedItemID)
|
||||
// let authors = ...
|
||||
let parsedItem = ParsedItem(syncServiceID: itemID, uniqueID: itemID, feedURL: String(item.feedID), url: nil, externalURL: item.url, title: item.title, contentHTML: item.body, contentText: nil, summary: nil, imageURL: nil, bannerImageURL: nil, datePublished: item.publishedDate, dateModified: item.updatedDate, authors: nil, tags: nil, attachments: nil)
|
||||
let parsedItem = ParsedItem(syncServiceID: itemID, uniqueID: itemID, feedURL: String(item.feedID), url: nil, externalURL: item.url, title: item.title, language: nil, contentHTML: item.body, contentText: nil, summary: nil, imageURL: nil, bannerImageURL: nil, datePublished: item.publishedDate, dateModified: item.updatedDate, authors: nil, tags: nil, attachments: nil)
|
||||
|
||||
return parsedItem
|
||||
}
|
||||
|
|
|
@ -42,6 +42,8 @@ final class FeedbinAccountDelegate: AccountDelegate {
|
|||
}
|
||||
}
|
||||
|
||||
var refreshProgress = DownloadProgress(numberOfTasks: 0)
|
||||
|
||||
init(dataFolder: String, transport: Transport?) {
|
||||
|
||||
let databaseFilePath = (dataFolder as NSString).appendingPathComponent("Sync.sqlite3")
|
||||
|
@ -72,7 +74,9 @@ final class FeedbinAccountDelegate: AccountDelegate {
|
|||
|
||||
}
|
||||
|
||||
var refreshProgress = DownloadProgress(numberOfTasks: 0)
|
||||
func receiveRemoteNotification(for account: Account, userInfo: [AnyHashable : Any], completion: @escaping () -> Void) {
|
||||
completion()
|
||||
}
|
||||
|
||||
func refreshAll(for account: Account, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
|
||||
|
@ -265,7 +269,7 @@ final class FeedbinAccountDelegate: AccountDelegate {
|
|||
|
||||
}
|
||||
|
||||
func addFolder(for account: Account, name: String, completion: @escaping (Result<Folder, Error>) -> Void) {
|
||||
func createFolder(for account: Account, name: String, completion: @escaping (Result<Folder, Error>) -> Void) {
|
||||
if let folder = account.ensureFolder(with: name) {
|
||||
completion(.success(folder))
|
||||
} else {
|
||||
|
@ -1233,7 +1237,7 @@ private extension FeedbinAccountDelegate {
|
|||
|
||||
let parsedItems: [ParsedItem] = entries.map { entry in
|
||||
let authors = Set([ParsedAuthor(name: entry.authorName, url: entry.jsonFeed?.jsonFeedAuthor?.url, avatarURL: entry.jsonFeed?.jsonFeedAuthor?.avatarURL, emailAddress: nil)])
|
||||
return ParsedItem(syncServiceID: String(entry.articleID), uniqueID: String(entry.articleID), feedURL: String(entry.feedID), url: entry.url, externalURL: nil, title: entry.title, contentHTML: entry.contentHTML, contentText: nil, summary: entry.summary, imageURL: nil, bannerImageURL: nil, datePublished: entry.parsedDatePublished, dateModified: nil, authors: authors, tags: nil, attachments: nil)
|
||||
return ParsedItem(syncServiceID: String(entry.articleID), uniqueID: String(entry.articleID), feedURL: String(entry.feedID), url: entry.url, externalURL: nil, title: entry.title, language: nil, contentHTML: entry.contentHTML, contentText: nil, summary: entry.summary, imageURL: nil, bannerImageURL: nil, datePublished: entry.parsedDatePublished, dateModified: nil, authors: authors, tags: nil, attachments: nil)
|
||||
}
|
||||
|
||||
return Set(parsedItems)
|
||||
|
|
|
@ -302,7 +302,7 @@ final class FeedlyAPICaller {
|
|||
|
||||
transport.send(request: request, resultType: [FeedlyFeed].self, dateDecoding: .millisecondsSince1970, keyDecoding: .convertFromSnakeCase) { result in
|
||||
switch result {
|
||||
case .success(let httpResponse, _):
|
||||
case .success((let httpResponse, _)):
|
||||
if httpResponse.statusCode == 200 {
|
||||
completion(.success(()))
|
||||
} else {
|
||||
|
@ -364,7 +364,7 @@ extension FeedlyAPICaller: FeedlyAddFeedToCollectionService {
|
|||
|
||||
transport.send(request: request, resultType: [FeedlyFeed].self, dateDecoding: .millisecondsSince1970, keyDecoding: .convertFromSnakeCase) { result in
|
||||
switch result {
|
||||
case .success(_, let collectionFeeds):
|
||||
case .success((_, let collectionFeeds)):
|
||||
if let feeds = collectionFeeds {
|
||||
completion(.success(feeds))
|
||||
} else {
|
||||
|
|
|
@ -95,6 +95,10 @@ final class FeedlyAccountDelegate: AccountDelegate {
|
|||
|
||||
// MARK: Account API
|
||||
|
||||
func receiveRemoteNotification(for account: Account, userInfo: [AnyHashable : Any], completion: @escaping () -> Void) {
|
||||
completion()
|
||||
}
|
||||
|
||||
func refreshAll(for account: Account, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
assert(Thread.isMainThread)
|
||||
|
||||
|
@ -212,7 +216,7 @@ final class FeedlyAccountDelegate: AccountDelegate {
|
|||
}
|
||||
}
|
||||
|
||||
func addFolder(for account: Account, name: String, completion: @escaping (Result<Folder, Error>) -> Void) {
|
||||
func createFolder(for account: Account, name: String, completion: @escaping (Result<Folder, Error>) -> Void) {
|
||||
|
||||
let progress = refreshProgress
|
||||
progress.addToNumberOfTasksAndRemaining(1)
|
||||
|
|
|
@ -98,6 +98,7 @@ struct FeedlyEntryParser {
|
|||
url: nil,
|
||||
externalURL: externalUrl,
|
||||
title: title,
|
||||
language: nil,
|
||||
contentHTML: contentHMTL,
|
||||
contentText: contentText,
|
||||
summary: summary,
|
||||
|
|
|
@ -70,12 +70,12 @@ public extension OAuthAuthorizationResponse {
|
|||
guard let queryItems = components.queryItems, !queryItems.isEmpty else {
|
||||
throw URLError(.unsupportedURL)
|
||||
}
|
||||
let code = queryItems.firstElementPassingTest { $0.name.lowercased() == "code" }
|
||||
let code = queryItems.first { $0.name.lowercased() == "code" }
|
||||
guard let codeValue = code?.value, !codeValue.isEmpty else {
|
||||
throw URLError(.unsupportedURL)
|
||||
}
|
||||
|
||||
let state = queryItems.firstElementPassingTest { $0.name.lowercased() == "state" }
|
||||
let state = queryItems.first { $0.name.lowercased() == "state" }
|
||||
let stateValue = state?.value
|
||||
|
||||
self.init(code: codeValue, state: stateValue)
|
||||
|
|
|
@ -18,6 +18,8 @@ public enum LocalAccountDelegateError: String, Error {
|
|||
|
||||
final class LocalAccountDelegate: AccountDelegate {
|
||||
|
||||
private let refresher = LocalAccountRefresher()
|
||||
|
||||
let behaviors: AccountBehaviors = []
|
||||
let isOPMLImportInProgress = false
|
||||
|
||||
|
@ -25,14 +27,17 @@ final class LocalAccountDelegate: AccountDelegate {
|
|||
var credentials: Credentials?
|
||||
var accountMetadata: AccountMetadata?
|
||||
|
||||
private let refresher = LocalAccountRefresher()
|
||||
let refreshProgress = DownloadProgress(numberOfTasks: 0)
|
||||
|
||||
var refreshProgress: DownloadProgress {
|
||||
return refresher.progress
|
||||
func receiveRemoteNotification(for account: Account, userInfo: [AnyHashable : Any], completion: @escaping () -> Void) {
|
||||
completion()
|
||||
}
|
||||
|
||||
func refreshAll(for account: Account, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
refresher.refreshFeeds(account.flattenedWebFeeds()) {
|
||||
let webFeeds = account.flattenedWebFeeds()
|
||||
refreshProgress.addToNumberOfTasksAndRemaining(webFeeds.count)
|
||||
refresher.refreshFeeds(webFeeds, feedCompletionBlock: { _ in self.refreshProgress.completeTask() }) {
|
||||
self.refreshProgress.clear()
|
||||
account.metadata.lastArticleFetchEndTime = Date()
|
||||
completion(.success(()))
|
||||
}
|
||||
|
@ -81,7 +86,7 @@ final class LocalAccountDelegate: AccountDelegate {
|
|||
}
|
||||
|
||||
BatchUpdate.shared.perform {
|
||||
account.loadOPMLItems(children, parentFolder: nil)
|
||||
account.loadOPMLItems(children)
|
||||
}
|
||||
|
||||
completion(.success(()))
|
||||
|
@ -163,7 +168,7 @@ final class LocalAccountDelegate: AccountDelegate {
|
|||
completion(.success(()))
|
||||
}
|
||||
|
||||
func addFolder(for account: Account, name: String, completion: @escaping (Result<Folder, Error>) -> Void) {
|
||||
func createFolder(for account: Account, name: String, completion: @escaping (Result<Folder, Error>) -> Void) {
|
||||
if let folder = account.ensureFolder(with: name) {
|
||||
completion(.success(folder))
|
||||
} else {
|
||||
|
|
|
@ -14,6 +14,7 @@ import Articles
|
|||
|
||||
final class LocalAccountRefresher {
|
||||
|
||||
private var feedCompletionBlock: ((WebFeed) -> Void)?
|
||||
private var completion: (() -> Void)?
|
||||
private var isSuspended = false
|
||||
|
||||
|
@ -21,11 +22,8 @@ final class LocalAccountRefresher {
|
|||
return DownloadSession(delegate: self)
|
||||
}()
|
||||
|
||||
var progress: DownloadProgress {
|
||||
return downloadSession.progress
|
||||
}
|
||||
|
||||
public func refreshFeeds(_ feeds: Set<WebFeed>, completion: @escaping () -> Void) {
|
||||
public func refreshFeeds(_ feeds: Set<WebFeed>, feedCompletionBlock: @escaping (WebFeed) -> Void, completion: @escaping () -> Void) {
|
||||
self.feedCompletionBlock = feedCompletionBlock
|
||||
self.completion = completion
|
||||
downloadSession.downloadObjects(feeds as NSSet)
|
||||
}
|
||||
|
@ -62,28 +60,37 @@ extension LocalAccountRefresher: DownloadSessionDelegate {
|
|||
}
|
||||
|
||||
func downloadSession(_ downloadSession: DownloadSession, downloadDidCompleteForRepresentedObject representedObject: AnyObject, response: URLResponse?, data: Data, error: NSError?, completion: @escaping () -> Void) {
|
||||
guard let feed = representedObject as? WebFeed, !data.isEmpty, !isSuspended else {
|
||||
let feed = representedObject as! WebFeed
|
||||
|
||||
guard !data.isEmpty, !isSuspended else {
|
||||
completion()
|
||||
feedCompletionBlock?(feed)
|
||||
return
|
||||
}
|
||||
|
||||
if let error = error {
|
||||
print("Error downloading \(feed.url) - \(error)")
|
||||
completion()
|
||||
feedCompletionBlock?(feed)
|
||||
return
|
||||
}
|
||||
|
||||
let dataHash = data.md5String
|
||||
if dataHash == feed.contentHash {
|
||||
completion()
|
||||
feedCompletionBlock?(feed)
|
||||
return
|
||||
}
|
||||
|
||||
let parserData = ParserData(url: feed.url, data: data)
|
||||
FeedParser.parse(parserData) { (parsedFeed, error) in
|
||||
|
||||
guard let account = feed.account, let parsedFeed = parsedFeed, error == nil else {
|
||||
completion()
|
||||
self.feedCompletionBlock?(feed)
|
||||
return
|
||||
}
|
||||
|
||||
account.update(feed, with: parsedFeed) { error in
|
||||
if error == nil {
|
||||
if let httpResponse = response as? HTTPURLResponse {
|
||||
|
@ -93,12 +100,16 @@ extension LocalAccountRefresher: DownloadSessionDelegate {
|
|||
feed.contentHash = dataHash
|
||||
}
|
||||
completion()
|
||||
self.feedCompletionBlock?(feed)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
func downloadSession(_ downloadSession: DownloadSession, shouldContinueAfterReceivingData data: Data, representedObject: AnyObject) -> Bool {
|
||||
guard !isSuspended, let feed = representedObject as? WebFeed else {
|
||||
let feed = representedObject as! WebFeed
|
||||
guard !isSuspended else {
|
||||
feedCompletionBlock?(feed)
|
||||
return false
|
||||
}
|
||||
|
||||
|
@ -107,21 +118,31 @@ extension LocalAccountRefresher: DownloadSessionDelegate {
|
|||
}
|
||||
|
||||
if data.isDefinitelyNotFeed() {
|
||||
feedCompletionBlock?(feed)
|
||||
return false
|
||||
}
|
||||
|
||||
if data.count > 4096 {
|
||||
let parserData = ParserData(url: feed.url, data: data)
|
||||
return FeedParser.mightBeAbleToParseBasedOnPartialData(parserData)
|
||||
if FeedParser.mightBeAbleToParseBasedOnPartialData(parserData) {
|
||||
return true
|
||||
} else {
|
||||
feedCompletionBlock?(feed)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func downloadSession(_ downloadSession: DownloadSession, didReceiveUnexpectedResponse response: URLResponse, representedObject: AnyObject) {
|
||||
let feed = representedObject as! WebFeed
|
||||
feedCompletionBlock?(feed)
|
||||
}
|
||||
|
||||
func downloadSession(_ downloadSession: DownloadSession, didReceiveNotModifiedResponse: URLResponse, representedObject: AnyObject) {
|
||||
let feed = representedObject as! WebFeed
|
||||
feedCompletionBlock?(feed)
|
||||
}
|
||||
|
||||
func downloadSessionDidCompleteDownloadObjects(_ downloadSession: DownloadSession) {
|
||||
|
|
|
@ -261,7 +261,7 @@ extension NewsBlurAccountDelegate {
|
|||
|
||||
let parsedItems: [ParsedItem] = stories.map { story in
|
||||
let author = Set([ParsedAuthor(name: story.authorName, url: nil, avatarURL: nil, emailAddress: nil)])
|
||||
return ParsedItem(syncServiceID: story.storyID, uniqueID: String(story.storyID), feedURL: String(story.feedID), url: story.url, externalURL: nil, title: story.title, contentHTML: story.contentHTML, contentText: nil, summary: nil, imageURL: story.imageURL, bannerImageURL: nil, datePublished: story.datePublished, dateModified: nil, authors: author, tags: Set(story.tags ?? []), attachments: nil)
|
||||
return ParsedItem(syncServiceID: story.storyID, uniqueID: String(story.storyID), feedURL: String(story.feedID), url: story.url, externalURL: nil, title: story.title, language: nil, contentHTML: story.contentHTML, contentText: nil, summary: nil, imageURL: story.imageURL, bannerImageURL: nil, datePublished: story.datePublished, dateModified: nil, authors: author, tags: Set(story.tags ?? []), attachments: nil)
|
||||
}
|
||||
|
||||
return Set(parsedItems)
|
||||
|
|
|
@ -57,6 +57,10 @@ final class NewsBlurAccountDelegate: AccountDelegate {
|
|||
database = SyncDatabase(databaseFilePath: dataFolder.appending("/DB.sqlite3"))
|
||||
}
|
||||
|
||||
func receiveRemoteNotification(for account: Account, userInfo: [AnyHashable : Any], completion: @escaping () -> Void) {
|
||||
completion()
|
||||
}
|
||||
|
||||
func refreshAll(for account: Account, completion: @escaping (Result<Void, Error>) -> ()) {
|
||||
self.refreshProgress.addToNumberOfTasksAndRemaining(5)
|
||||
|
||||
|
@ -335,7 +339,7 @@ final class NewsBlurAccountDelegate: AccountDelegate {
|
|||
completion(.success(()))
|
||||
}
|
||||
|
||||
func addFolder(for account: Account, name: String, completion: @escaping (Result<Folder, Error>) -> ()) {
|
||||
func createFolder(for account: Account, name: String, completion: @escaping (Result<Folder, Error>) -> ()) {
|
||||
self.refreshProgress.addToNumberOfTasksAndRemaining(1)
|
||||
|
||||
caller.addFolder(named: name) { result in
|
||||
|
@ -542,7 +546,7 @@ final class NewsBlurAccountDelegate: AccountDelegate {
|
|||
let group = DispatchGroup()
|
||||
|
||||
group.enter()
|
||||
addFolder(for: account, name: folderName) { result in
|
||||
createFolder(for: account, name: folderName) { result in
|
||||
group.leave()
|
||||
switch result {
|
||||
case .success(let folder):
|
||||
|
|
|
@ -40,7 +40,7 @@ final class OPMLFile {
|
|||
}
|
||||
|
||||
BatchUpdate.shared.perform {
|
||||
account.loadOPMLItems(opmlItems, parentFolder: nil)
|
||||
account.loadOPMLItems(opmlItems)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,60 @@
|
|||
//
|
||||
// OPMLNormalizer.swift
|
||||
// Account
|
||||
//
|
||||
// Created by Maurice Parker on 3/31/20.
|
||||
// Copyright © 2020 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import RSParser
|
||||
|
||||
final class OPMLNormalizer {
|
||||
|
||||
var normalizedOPMLItems = [RSOPMLItem]()
|
||||
|
||||
static func normalize(_ items: [RSOPMLItem]) -> [RSOPMLItem] {
|
||||
let opmlNormalizer = OPMLNormalizer()
|
||||
opmlNormalizer.normalize(items)
|
||||
return opmlNormalizer.normalizedOPMLItems
|
||||
}
|
||||
|
||||
private func normalize(_ items: [RSOPMLItem], parentFolder: RSOPMLItem? = nil) {
|
||||
var feedsToAdd = [RSOPMLItem]()
|
||||
|
||||
items.forEach { (item) in
|
||||
|
||||
if let _ = item.feedSpecifier {
|
||||
if !feedsToAdd.contains(where: { $0.feedSpecifier?.feedURL == item.feedSpecifier?.feedURL } ) {
|
||||
feedsToAdd.append(item)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
guard let _ = 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 {
|
||||
normalize(itemChildren, parentFolder: parentFolder)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
normalizedOPMLItems.append(item)
|
||||
if let itemChildren = item.children {
|
||||
normalize(itemChildren, parentFolder: item)
|
||||
}
|
||||
}
|
||||
|
||||
if let parentFolder = parentFolder {
|
||||
for feed in feedsToAdd {
|
||||
parentFolder.addChild(feed)
|
||||
}
|
||||
} else {
|
||||
for feed in feedsToAdd {
|
||||
normalizedOPMLItems.append(feed)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
|
@ -47,6 +47,8 @@ final class ReaderAPIAccountDelegate: AccountDelegate {
|
|||
}
|
||||
}
|
||||
|
||||
var refreshProgress = DownloadProgress(numberOfTasks: 0)
|
||||
|
||||
init(dataFolder: String, transport: Transport?) {
|
||||
|
||||
let databaseFilePath = (dataFolder as NSString).appendingPathComponent("Sync.sqlite3")
|
||||
|
@ -77,7 +79,9 @@ final class ReaderAPIAccountDelegate: AccountDelegate {
|
|||
|
||||
}
|
||||
|
||||
var refreshProgress = DownloadProgress(numberOfTasks: 0)
|
||||
func receiveRemoteNotification(for account: Account, userInfo: [AnyHashable : Any], completion: @escaping () -> Void) {
|
||||
completion()
|
||||
}
|
||||
|
||||
func refreshAll(for account: Account, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
|
||||
|
@ -202,7 +206,7 @@ final class ReaderAPIAccountDelegate: AccountDelegate {
|
|||
func importOPML(for account:Account, opmlFile: URL, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
}
|
||||
|
||||
func addFolder(for account: Account, name: String, completion: @escaping (Result<Folder, Error>) -> Void) {
|
||||
func createFolder(for account: Account, name: String, completion: @escaping (Result<Folder, Error>) -> Void) {
|
||||
if let folder = account.ensureFolder(with: name) {
|
||||
completion(.success(folder))
|
||||
} else {
|
||||
|
@ -932,7 +936,7 @@ private extension ReaderAPIAccountDelegate {
|
|||
// let authors = Set([ParsedAuthor(name: entry.authorName, url: entry.jsonFeed?.jsonFeedAuthor?.url, avatarURL: entry.jsonFeed?.jsonFeedAuthor?.avatarURL, emailAddress: nil)])
|
||||
// let feed = account.idToFeedDictionary[entry.origin.streamId!]! // TODO clean this up
|
||||
|
||||
return ParsedItem(syncServiceID: entry.uniqueID(), uniqueID: entry.uniqueID(), feedURL: entry.origin.streamId!, url: nil, externalURL: entry.alternates.first?.url, title: entry.title, contentHTML: entry.summary.content, contentText: nil, summary: entry.summary.content, imageURL: nil, bannerImageURL: nil, datePublished: entry.parseDatePublished(), dateModified: nil, authors: nil, tags: nil, attachments: nil)
|
||||
return ParsedItem(syncServiceID: entry.uniqueID(), uniqueID: entry.uniqueID(), feedURL: entry.origin.streamId!, url: nil, externalURL: entry.alternates.first?.url, title: entry.title, language: nil, contentHTML: entry.summary.content, contentText: nil, summary: entry.summary.content, imageURL: nil, bannerImageURL: nil, datePublished: entry.parseDatePublished(), dateModified: nil, authors: nil, tags: nil, attachments: nil)
|
||||
}
|
||||
|
||||
return Set(parsedItems)
|
||||
|
|
|
@ -43,14 +43,21 @@ 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) {
|
||||
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)
|
||||
self.articlesTable = ArticlesTable(name: DatabaseTableName.articles, accountID: accountID, queue: queue, retentionStyle: retentionStyle)
|
||||
self.retentionStyle = retentionStyle
|
||||
|
||||
try! queue.runCreateStatements(ArticlesDatabase.tableCreationStatements)
|
||||
queue.runInDatabase { databaseResult in
|
||||
|
@ -62,7 +69,6 @@ public final class ArticlesDatabase {
|
|||
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;")
|
||||
}
|
||||
|
||||
// queue.vacuumIfNeeded(daysBetweenVacuums: 9) // TODO: restore this after we do database cleanups.
|
||||
DispatchQueue.main.async {
|
||||
self.articlesTable.indexUnindexedArticles()
|
||||
}
|
||||
|
@ -183,8 +189,15 @@ public final class ArticlesDatabase {
|
|||
|
||||
// MARK: - Saving and Updating Articles
|
||||
|
||||
/// Update articles and save new ones.
|
||||
/// Update articles and save new ones — for feed-based systems (local and iCloud).
|
||||
public func update(with parsedItems: Set<ParsedItem>, webFeedID: String, completion: @escaping UpdateArticlesCompletionBlock) {
|
||||
precondition(retentionStyle == .feedBased)
|
||||
articlesTable.update(parsedItems, webFeedID, completion)
|
||||
}
|
||||
|
||||
/// Update articles and save new ones — for sync systems (Feedbin, Feedly, etc.).
|
||||
public func update(webFeedIDsAndItems: [String: Set<ParsedItem>], defaultRead: Bool, completion: @escaping UpdateArticlesCompletionBlock) {
|
||||
precondition(retentionStyle == .syncSystem)
|
||||
articlesTable.update(webFeedIDsAndItems, defaultRead, completion)
|
||||
}
|
||||
|
||||
|
@ -219,6 +232,7 @@ public final class ArticlesDatabase {
|
|||
articlesTable.createStatusesIfNeeded(articleIDs, completion)
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
// MARK: - Suspend and Resume (for iOS)
|
||||
|
||||
/// Cancel current operations and close the database.
|
||||
|
@ -239,6 +253,7 @@ public final class ArticlesDatabase {
|
|||
queue.resume()
|
||||
operationQueue.resume()
|
||||
}
|
||||
#endif
|
||||
|
||||
// MARK: - Caches
|
||||
|
||||
|
@ -254,7 +269,9 @@ public final class ArticlesDatabase {
|
|||
|
||||
/// Calls the various clean-up functions.
|
||||
public func cleanupDatabaseAtStartup(subscribedToWebFeedIDs: Set<String>) {
|
||||
if retentionStyle == .syncSystem {
|
||||
articlesTable.deleteOldArticles()
|
||||
}
|
||||
articlesTable.deleteArticlesNotInSubscribedToFeedIDs(subscribedToWebFeedIDs)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -19,6 +19,8 @@ final class ArticlesTable: DatabaseTable {
|
|||
private let queue: DatabaseQueue
|
||||
private let statusesTable: StatusesTable
|
||||
private let authorsLookupTable: DatabaseLookupTable
|
||||
private let retentionStyle: ArticlesDatabase.RetentionStyle
|
||||
|
||||
private var articlesCache = [String: Article]()
|
||||
|
||||
private lazy var searchTable: SearchTable = {
|
||||
|
@ -30,12 +32,13 @@ final class ArticlesTable: DatabaseTable {
|
|||
|
||||
private typealias ArticlesFetchMethod = (FMDatabase) -> Set<Article>
|
||||
|
||||
init(name: String, accountID: String, queue: DatabaseQueue) {
|
||||
init(name: String, accountID: String, queue: DatabaseQueue, retentionStyle: ArticlesDatabase.RetentionStyle) {
|
||||
|
||||
self.name = name
|
||||
self.accountID = accountID
|
||||
self.queue = queue
|
||||
self.statusesTable = StatusesTable(queue: queue)
|
||||
self.retentionStyle = retentionStyle
|
||||
|
||||
let authorsTable = AuthorsTable(name: DatabaseTableName.authors)
|
||||
self.authorsLookupTable = DatabaseLookupTable(name: DatabaseTableName.authorsLookup, objectIDKey: DatabaseKey.articleID, relatedObjectIDKey: DatabaseKey.authorID, relatedTable: authorsTable, relationshipName: RelationshipName.authors)
|
||||
|
@ -169,7 +172,78 @@ final class ArticlesTable: DatabaseTable {
|
|||
|
||||
// MARK: - Updating
|
||||
|
||||
func update(_ parsedItems: Set<ParsedItem>, _ webFeedID: String, _ completion: @escaping UpdateArticlesCompletionBlock) {
|
||||
precondition(retentionStyle == .feedBased)
|
||||
if parsedItems.isEmpty {
|
||||
callUpdateArticlesCompletionBlock(nil, nil, completion)
|
||||
return
|
||||
}
|
||||
|
||||
// 1. Ensure statuses for all the incoming articles.
|
||||
// 2. Create incoming articles with parsedItems.
|
||||
// 3. Ignore incoming articles that are userDeleted
|
||||
// 4. Fetch all articles for the feed.
|
||||
// 5. Create array of Articles not in database and save them.
|
||||
// 6. Create array of updated Articles and save what’s changed.
|
||||
// 7. Call back with new and updated Articles.
|
||||
// 8. Delete Articles in database no longer present in the feed.
|
||||
// 9. Update search index.
|
||||
|
||||
self.queue.runInTransaction { (databaseResult) in
|
||||
|
||||
func makeDatabaseCalls(_ database: FMDatabase) {
|
||||
let articleIDs = parsedItems.articleIDs()
|
||||
|
||||
let statusesDictionary = self.statusesTable.ensureStatusesForArticleIDs(articleIDs, false, database) //1
|
||||
assert(statusesDictionary.count == articleIDs.count)
|
||||
|
||||
let allIncomingArticles = Article.articlesWithParsedItems(parsedItems, webFeedID, self.accountID, statusesDictionary) //2
|
||||
let incomingArticles = Set(allIncomingArticles.filter { !($0.status.userDeleted) }) //3
|
||||
if incomingArticles.isEmpty {
|
||||
self.callUpdateArticlesCompletionBlock(nil, nil, completion)
|
||||
return
|
||||
}
|
||||
|
||||
let fetchedArticles = self.fetchArticlesForFeedID(webFeedID, withLimits: false, database) //4
|
||||
let fetchedArticlesDictionary = fetchedArticles.dictionary()
|
||||
|
||||
let newArticles = self.findAndSaveNewArticles(incomingArticles, fetchedArticlesDictionary, database) //5
|
||||
let updatedArticles = self.findAndSaveUpdatedArticles(incomingArticles, fetchedArticlesDictionary, database) //6
|
||||
|
||||
self.callUpdateArticlesCompletionBlock(newArticles, updatedArticles, completion) //7
|
||||
|
||||
self.addArticlesToCache(newArticles)
|
||||
self.addArticlesToCache(updatedArticles)
|
||||
|
||||
// 8. Delete articles no longer in feed.
|
||||
let articleIDsToDelete = fetchedArticles.articleIDs().filter { !(articleIDs.contains($0)) }
|
||||
if !articleIDsToDelete.isEmpty {
|
||||
self.removeArticles(articleIDsToDelete, database)
|
||||
self.removeArticleIDsFromCache(articleIDsToDelete)
|
||||
}
|
||||
|
||||
// 9. Update search index.
|
||||
if let newArticles = newArticles {
|
||||
self.searchTable.indexNewArticles(newArticles, database)
|
||||
}
|
||||
if let updatedArticles = updatedArticles {
|
||||
self.searchTable.indexUpdatedArticles(updatedArticles, database)
|
||||
}
|
||||
}
|
||||
|
||||
switch databaseResult {
|
||||
case .success(let database):
|
||||
makeDatabaseCalls(database)
|
||||
case .failure(let databaseError):
|
||||
DispatchQueue.main.async {
|
||||
completion(.failure(databaseError))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func update(_ webFeedIDsAndItems: [String: Set<ParsedItem>], _ read: Bool, _ completion: @escaping UpdateArticlesCompletionBlock) {
|
||||
precondition(retentionStyle == .syncSystem)
|
||||
if webFeedIDsAndItems.isEmpty {
|
||||
callUpdateArticlesCompletionBlock(nil, nil, completion)
|
||||
return
|
||||
|
@ -850,6 +924,12 @@ private extension ArticlesTable {
|
|||
}
|
||||
}
|
||||
|
||||
func removeArticleIDsFromCache(_ articleIDs: Set<String>) {
|
||||
for articleID in articleIDs {
|
||||
articlesCache[articleID] = nil
|
||||
}
|
||||
}
|
||||
|
||||
func articleIsIgnorable(_ article: Article) -> Bool {
|
||||
// Ignorable articles: either userDeleted==1 or (not starred and arrival date > 4 months).
|
||||
if article.status.userDeleted {
|
||||
|
@ -863,6 +943,7 @@ private extension ArticlesTable {
|
|||
|
||||
func filterIncomingArticles(_ articles: Set<Article>) -> Set<Article> {
|
||||
// Drop Articles that we can ignore.
|
||||
precondition(retentionStyle == .syncSystem)
|
||||
return Set(articles.filter{ !articleIsIgnorable($0) })
|
||||
}
|
||||
|
||||
|
|
|
@ -112,8 +112,12 @@ extension Article {
|
|||
// 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 articlesWithWebFeedIDsAndItems(_ webFeedIDsAndItems: [String: Set<ParsedItem>], _ accountID: String, _ statusesDictionary: [String: ArticleStatus]) -> Set<Article> {
|
||||
let maximumDateAllowed = Date().addingTimeInterval(60 * 60 * 24) // Allow dates up to about 24 hours ahead of now
|
||||
let maximumDateAllowed = _maximumDateAllowed()
|
||||
var feedArticles = Set<Article>()
|
||||
for (webFeedID, parsedItems) in webFeedIDsAndItems {
|
||||
for parsedItem in parsedItems {
|
||||
|
@ -124,6 +128,11 @@ extension Article {
|
|||
}
|
||||
return feedArticles
|
||||
}
|
||||
|
||||
static func articlesWithParsedItems(_ parsedItems: Set<ParsedItem>, _ webFeedID: String, _ accountID: String, _ statusesDictionary: [String: ArticleStatus]) -> Set<Article> {
|
||||
let maximumDateAllowed = _maximumDateAllowed()
|
||||
return Set(parsedItems.map{ Article(parsedItem: $0, maximumDateAllowed: maximumDateAllowed, accountID: accountID, webFeedID: webFeedID, status: statusesDictionary[$0.articleID]!) })
|
||||
}
|
||||
}
|
||||
|
||||
extension Article: DatabaseObject {
|
||||
|
|
|
@ -41,6 +41,10 @@ struct AppAssets {
|
|||
return RSImage(named: "accountFreshRSS")
|
||||
}()
|
||||
|
||||
static var accountNewsBlur: RSImage! = {
|
||||
return RSImage(named: "accountNewsBlur")
|
||||
}()
|
||||
|
||||
static var articleExtractor: RSImage! = {
|
||||
return RSImage(named: "articleExtractor")
|
||||
}()
|
||||
|
@ -151,8 +155,8 @@ struct AppAssets {
|
|||
return AppAssets.accountFeedWrangler
|
||||
case .freshRSS:
|
||||
return AppAssets.accountFreshRSS
|
||||
default:
|
||||
return nil
|
||||
case .newsBlur:
|
||||
return AppAssets.accountNewsBlur
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -48,6 +48,13 @@ struct AppDefaults {
|
|||
private static let smallestFontSizeRawValue = FontSize.small.rawValue
|
||||
private static let largestFontSizeRawValue = FontSize.veryLarge.rawValue
|
||||
|
||||
static let isDeveloperBuild: Bool = {
|
||||
if let dev = Bundle.main.object(forInfoDictionaryKey: "DeveloperEntitlements") as? String, dev == "-dev" {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}()
|
||||
|
||||
static let isFirstRun: Bool = {
|
||||
if let _ = UserDefaults.standard.object(forKey: Key.firstRunDate) as? Date {
|
||||
return false
|
||||
|
|
|
@ -231,13 +231,8 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSUserInterfaceValidations,
|
|||
refreshTimer = AccountRefreshTimer()
|
||||
syncTimer = ArticleStatusSyncTimer()
|
||||
|
||||
UNUserNotificationCenter.current().requestAuthorization(options:[.badge, .sound, .alert]) { (granted, error) in
|
||||
if granted {
|
||||
DispatchQueue.main.async {
|
||||
UNUserNotificationCenter.current().requestAuthorization(options:[.badge, .sound, .alert]) { (granted, error) in }
|
||||
NSApplication.shared.registerForRemoteNotifications()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
UNUserNotificationCenter.current().delegate = self
|
||||
userNotificationManager = UserNotificationManager()
|
||||
|
@ -267,6 +262,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSUserInterfaceValidations,
|
|||
CrashReporter.check(appName: "NetNewsWire")
|
||||
}
|
||||
#endif
|
||||
|
||||
}
|
||||
|
||||
func application(_ application: NSApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([NSUserActivityRestoring]) -> Void) -> Bool {
|
||||
|
@ -296,37 +292,27 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSUserInterfaceValidations,
|
|||
}
|
||||
|
||||
func applicationDidResignActive(_ notification: Notification) {
|
||||
|
||||
ArticleStringFormatter.emptyCaches()
|
||||
|
||||
saveState()
|
||||
}
|
||||
|
||||
func application(_ application: NSApplication, didReceiveRemoteNotification userInfo: [String : Any]) {
|
||||
AccountManager.shared.receiveRemoteNotification(userInfo: userInfo)
|
||||
}
|
||||
|
||||
func applicationWillTerminate(_ notification: Notification) {
|
||||
shuttingDown = true
|
||||
saveState()
|
||||
|
||||
let group = DispatchGroup()
|
||||
|
||||
group.enter()
|
||||
AccountManager.shared.syncArticleStatusAll() {
|
||||
group.leave()
|
||||
}
|
||||
|
||||
let timeout = DispatchTime.now() + .seconds(1)
|
||||
_ = group.wait(timeout: timeout)
|
||||
}
|
||||
|
||||
// MARK: Notifications
|
||||
@objc func unreadCountDidChange(_ note: Notification) {
|
||||
|
||||
if note.object is AccountManager {
|
||||
unreadCount = AccountManager.shared.unreadCount
|
||||
}
|
||||
}
|
||||
|
||||
@objc func webFeedSettingDidChange(_ note: Notification) {
|
||||
|
||||
guard let feed = note.object as? WebFeed, let key = note.userInfo?[WebFeed.WebFeedSettingUserInfoKey] as? String else {
|
||||
return
|
||||
}
|
||||
|
@ -336,7 +322,6 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSUserInterfaceValidations,
|
|||
}
|
||||
|
||||
@objc func inspectableObjectsDidChange(_ note: Notification) {
|
||||
|
||||
guard let inspectorWindowController = inspectorWindowController, inspectorWindowController.isOpen else {
|
||||
return
|
||||
}
|
||||
|
|
|
@ -58,16 +58,12 @@ class AddFeedController: AddFeedWindowControllerDelegate {
|
|||
return
|
||||
}
|
||||
|
||||
BatchUpdate.shared.start()
|
||||
|
||||
account.createWebFeed(url: url.absoluteString, name: title, container: container) { result in
|
||||
|
||||
DispatchQueue.main.async {
|
||||
self.endShowingProgress()
|
||||
}
|
||||
|
||||
BatchUpdate.shared.end()
|
||||
|
||||
switch result {
|
||||
case .success(let feed):
|
||||
NotificationCenter.default.post(name: .UserDidAddFeed, object: self, userInfo: [UserInfoKey.webFeed: feed])
|
||||
|
|
|
@ -799,6 +799,10 @@ private extension MainWindowController {
|
|||
}
|
||||
|
||||
func validateToggleArticleExtractor(_ item: NSValidatedUserInterfaceItem) -> Bool {
|
||||
guard !AppDefaults.isDeveloperBuild else {
|
||||
return false
|
||||
}
|
||||
|
||||
guard let toolbarItem = item as? NSToolbarItem, let toolbarButton = toolbarItem.view as? ArticleExtractorButton else {
|
||||
if let menuItem = item as? NSMenuItem {
|
||||
menuItem.state = isShowingExtractedArticle ? .on : .off
|
||||
|
|
|
@ -75,7 +75,6 @@ protocol SidebarDelegate: class {
|
|||
NotificationCenter.default.addObserver(self, selector: #selector(faviconDidBecomeAvailable(_:)), name: .FaviconDidBecomeAvailable, object: nil)
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(webFeedSettingDidChange(_:)), name: .WebFeedSettingDidChange, object: nil)
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(displayNameDidChange(_:)), name: .DisplayNameDidChange, object: nil)
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(downloadArticlesDidUpdateUnreadCounts(_:)), name: .DownloadArticlesDidUpdateUnreadCounts, object: nil)
|
||||
|
||||
outlineView.reloadData()
|
||||
|
||||
|
@ -218,10 +217,6 @@ protocol SidebarDelegate: class {
|
|||
revealAndSelectRepresentedObject(feed as AnyObject)
|
||||
}
|
||||
|
||||
@objc func downloadArticlesDidUpdateUnreadCounts(_ note: Notification) {
|
||||
rebuildTreeAndRestoreSelection()
|
||||
}
|
||||
|
||||
// MARK: - Actions
|
||||
|
||||
@IBAction func delete(_ sender: AnyObject?) {
|
||||
|
|
|
@ -53,7 +53,7 @@ final class SingleLineTextFieldSizer {
|
|||
// that members of such a dictionary were mutated after insertion.
|
||||
// We use just an array of sizers now — which is totally fine,
|
||||
// because there’s only going to be like three of them.
|
||||
if let cachedSizer = sizers.firstElementPassingTest({ $0.font == font }) {
|
||||
if let cachedSizer = sizers.first(where: { $0.font == font }) {
|
||||
return cachedSizer
|
||||
}
|
||||
|
||||
|
|
|
@ -476,7 +476,7 @@ final class TimelineViewController: NSViewController, UndoableCommandRunner, Unr
|
|||
|
||||
if isReadFiltered ?? false {
|
||||
if let accountName = userInfo[ArticlePathKey.accountName] as? String,
|
||||
let account = AccountManager.shared.findActiveAccount(forDisplayName: accountName) {
|
||||
let account = AccountManager.shared.existingActiveAccount(forDisplayName: accountName) {
|
||||
exceptionArticleFetcher = SingleArticleFetcher(account: account, articleID: articleID)
|
||||
fetchAndReplaceArticlesSync()
|
||||
}
|
||||
|
|
|
@ -35,7 +35,7 @@ class AccountsAddCloudKitWindowController: NSWindowController {
|
|||
}
|
||||
|
||||
@IBAction func create(_ sender: Any) {
|
||||
_ = AccountManager.shared.createAccount(type: .cloudKit)
|
||||
let _ = AccountManager.shared.createAccount(type: .cloudKit)
|
||||
hostWindow!.endSheet(window!, returnCode: NSApplication.ModalResponse.OK)
|
||||
}
|
||||
|
||||
|
|
|
@ -17,7 +17,7 @@ class AccountsAddViewController: NSViewController {
|
|||
private var accountsAddWindowController: NSWindowController?
|
||||
|
||||
#if DEBUG
|
||||
private var addableAccountTypes: [AccountType] = [.onMyMac, .cloudKit, .feedbin, .feedly, .feedWrangler, .freshRSS]
|
||||
private var addableAccountTypes: [AccountType] = [.onMyMac, .feedbin, .feedly, .feedWrangler, .freshRSS, .cloudKit, .newsBlur]
|
||||
#else
|
||||
private var addableAccountTypes: [AccountType] = [.onMyMac, .feedbin, .feedly]
|
||||
#endif
|
||||
|
@ -34,7 +34,7 @@ class AccountsAddViewController: NSViewController {
|
|||
super.viewDidLoad()
|
||||
tableView.dataSource = self
|
||||
tableView.delegate = self
|
||||
removeCloudKitIfNecessary()
|
||||
restrictAccounts()
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -80,8 +80,9 @@ extension AccountsAddViewController: NSTableViewDelegate {
|
|||
case .feedly:
|
||||
cell.accountNameLabel?.stringValue = NSLocalizedString("Feedly", comment: "Feedly")
|
||||
cell.accountImageView?.image = AppAssets.accountFeedly
|
||||
default:
|
||||
break
|
||||
case .newsBlur:
|
||||
cell.accountNameLabel?.stringValue = NSLocalizedString("NewsBlur", comment: "NewsBlur")
|
||||
cell.accountImageView?.image = AppAssets.accountNewsBlur
|
||||
}
|
||||
return cell
|
||||
}
|
||||
|
@ -104,7 +105,7 @@ extension AccountsAddViewController: NSTableViewDelegate {
|
|||
let accountsAddCloudKitWindowController = AccountsAddCloudKitWindowController()
|
||||
accountsAddCloudKitWindowController.runSheetOnWindow(self.view.window!) { response in
|
||||
if response == NSApplication.ModalResponse.OK {
|
||||
self.removeCloudKitIfNecessary()
|
||||
self.restrictAccounts()
|
||||
self.tableView.reloadData()
|
||||
}
|
||||
}
|
||||
|
@ -127,8 +128,10 @@ extension AccountsAddViewController: NSTableViewDelegate {
|
|||
addAccount.delegate = self
|
||||
addAccount.presentationAnchor = self.view.window!
|
||||
MainThreadOperationQueue.shared.add(addAccount)
|
||||
default:
|
||||
break
|
||||
case .newsBlur:
|
||||
let accountsNewsBlurWindowController = AccountsNewsBlurWindowController()
|
||||
accountsNewsBlurWindowController.runSheetOnWindow(self.view.window!)
|
||||
accountsAddWindowController = accountsNewsBlurWindowController
|
||||
}
|
||||
|
||||
tableView.selectRowIndexes([], byExtendingSelection: false)
|
||||
|
@ -161,21 +164,22 @@ extension AccountsAddViewController: OAuthAccountAuthorizationOperationDelegate
|
|||
|
||||
private extension AccountsAddViewController {
|
||||
|
||||
func removeCloudKitIfNecessary() {
|
||||
func removeCloudKit() {
|
||||
if let cloudKitIndex = addableAccountTypes.firstIndex(of: .cloudKit) {
|
||||
addableAccountTypes.remove(at: cloudKitIndex)
|
||||
func restrictAccounts() {
|
||||
func removeAccountType(_ accountType: AccountType) {
|
||||
if let index = addableAccountTypes.firstIndex(of: accountType) {
|
||||
addableAccountTypes.remove(at: index)
|
||||
}
|
||||
}
|
||||
|
||||
if AccountManager.shared.activeAccounts.firstIndex(where: { $0.type == .cloudKit }) != nil {
|
||||
removeCloudKit()
|
||||
if AppDefaults.isDeveloperBuild {
|
||||
removeAccountType(.cloudKit)
|
||||
removeAccountType(.feedly)
|
||||
removeAccountType(.feedWrangler)
|
||||
return
|
||||
}
|
||||
|
||||
// We don't want developers without entitlements to be trying to add the CloudKit account
|
||||
if let dev = Bundle.main.object(forInfoDictionaryKey: "DeveloperEntitlements") as? String, dev == "-dev" {
|
||||
removeCloudKit()
|
||||
if AccountManager.shared.activeAccounts.firstIndex(where: { $0.type == .cloudKit }) != nil {
|
||||
removeAccountType(.cloudKit)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,185 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<document type="com.apple.InterfaceBuilder3.Cocoa.XIB" version="3.0" toolsVersion="15702" targetRuntime="MacOSX.Cocoa" propertyAccessControl="none" useAutolayout="YES" customObjectInstantitationMethod="direct">
|
||||
<dependencies>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.CocoaPlugin" version="15702"/>
|
||||
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
|
||||
</dependencies>
|
||||
<objects>
|
||||
<customObject id="-2" userLabel="File's Owner" customClass="AccountsNewsBlurWindowController" customModule="NetNewsWire" customModuleProvider="target">
|
||||
<connections>
|
||||
<outlet property="actionButton" destination="9mz-D9-krh" id="ozu-6Q-9Lb"/>
|
||||
<outlet property="errorMessageLabel" destination="byK-Sd-r7F" id="8zt-9d-dWQ"/>
|
||||
<outlet property="passwordTextField" destination="JSa-LY-zNQ" id="5cF-bM-CJE"/>
|
||||
<outlet property="progressIndicator" destination="B0W-bh-Evv" id="Tiq-gx-s3F"/>
|
||||
<outlet property="usernameTextField" destination="78p-Cf-f55" id="Gg5-Ce-RJv"/>
|
||||
<outlet property="window" destination="F0z-JX-Cv5" id="gIp-Ho-8D9"/>
|
||||
</connections>
|
||||
</customObject>
|
||||
<customObject id="-1" userLabel="First Responder" customClass="FirstResponder"/>
|
||||
<customObject id="-3" userLabel="Application" customClass="NSObject"/>
|
||||
<window title="Window" allowsToolTipsWhenApplicationIsInactive="NO" autorecalculatesKeyViewLoop="NO" releasedWhenClosed="NO" visibleAtLaunch="NO" animationBehavior="default" id="F0z-JX-Cv5">
|
||||
<windowStyleMask key="styleMask" titled="YES" closable="YES" miniaturizable="YES" resizable="YES"/>
|
||||
<windowPositionMask key="initialPositionMask" leftStrut="YES" rightStrut="YES" topStrut="YES" bottomStrut="YES"/>
|
||||
<rect key="contentRect" x="196" y="240" width="433" height="249"/>
|
||||
<rect key="screenRect" x="0.0" y="0.0" width="1920" height="1057"/>
|
||||
<view key="contentView" id="se5-gp-TjO">
|
||||
<rect key="frame" x="0.0" y="0.0" width="433" height="249"/>
|
||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||
<subviews>
|
||||
<stackView distribution="fill" orientation="horizontal" alignment="bottom" spacing="19" horizontalStackHuggingPriority="249.99998474121094" verticalStackHuggingPriority="249.99998474121094" detachesHiddenViews="YES" translatesAutoresizingMaskIntoConstraints="NO" id="7Ht-Fn-0Ya">
|
||||
<rect key="frame" x="123" y="191" width="188" height="38"/>
|
||||
<subviews>
|
||||
<imageView horizontalHuggingPriority="251" verticalHuggingPriority="251" translatesAutoresizingMaskIntoConstraints="NO" id="Ssh-Dh-xbg">
|
||||
<rect key="frame" x="0.0" y="0.0" width="36" height="36"/>
|
||||
<constraints>
|
||||
<constraint firstAttribute="height" constant="36" id="Ern-Kk-8LX"/>
|
||||
<constraint firstAttribute="width" constant="36" id="PLS-68-NMc"/>
|
||||
</constraints>
|
||||
<imageCell key="cell" refusesFirstResponder="YES" alignment="left" imageScaling="proportionallyDown" image="accountNewsBlur" id="y38-YL-woC"/>
|
||||
</imageView>
|
||||
<textField horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="lti-yM-8LV">
|
||||
<rect key="frame" x="53" y="0.0" width="137" height="38"/>
|
||||
<textFieldCell key="cell" lineBreakMode="clipping" title="NewsBlur" id="ras-dj-nP8">
|
||||
<font key="font" metaFont="system" size="32"/>
|
||||
<color key="textColor" name="labelColor" catalog="System" colorSpace="catalog"/>
|
||||
<color key="backgroundColor" name="textBackgroundColor" catalog="System" colorSpace="catalog"/>
|
||||
</textFieldCell>
|
||||
</textField>
|
||||
</subviews>
|
||||
<visibilityPriorities>
|
||||
<integer value="1000"/>
|
||||
<integer value="1000"/>
|
||||
</visibilityPriorities>
|
||||
<customSpacing>
|
||||
<real value="3.4028234663852886e+38"/>
|
||||
<real value="3.4028234663852886e+38"/>
|
||||
</customSpacing>
|
||||
</stackView>
|
||||
<gridView xPlacement="trailing" yPlacement="center" rowAlignment="none" rowSpacing="12" columnSpacing="14" translatesAutoresizingMaskIntoConstraints="NO" id="zBB-JH-huI">
|
||||
<rect key="frame" x="51" y="61" width="332" height="98"/>
|
||||
<rows>
|
||||
<gridRow id="DRl-lC-vUc"/>
|
||||
<gridRow id="eW8-uH-txq"/>
|
||||
<gridRow id="DbI-7g-Xme"/>
|
||||
</rows>
|
||||
<columns>
|
||||
<gridColumn id="fCQ-jY-Mts"/>
|
||||
<gridColumn xPlacement="leading" id="7CY-bX-6x4"/>
|
||||
</columns>
|
||||
<gridCells>
|
||||
<gridCell row="DRl-lC-vUc" column="fCQ-jY-Mts" id="4DI-01-jGD">
|
||||
<textField key="contentView" horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="Zy6-9c-8TI">
|
||||
<rect key="frame" x="-2" y="80" width="122" height="16"/>
|
||||
<textFieldCell key="cell" lineBreakMode="clipping" title="Username or Email:" id="DqN-SV-v35">
|
||||
<font key="font" usesAppearanceFont="YES"/>
|
||||
<color key="textColor" name="labelColor" catalog="System" colorSpace="catalog"/>
|
||||
<color key="backgroundColor" name="textBackgroundColor" catalog="System" colorSpace="catalog"/>
|
||||
</textFieldCell>
|
||||
</textField>
|
||||
</gridCell>
|
||||
<gridCell row="DRl-lC-vUc" column="7CY-bX-6x4" id="Z0b-qS-MUJ">
|
||||
<textField key="contentView" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="78p-Cf-f55">
|
||||
<rect key="frame" x="132" y="77" width="200" height="21"/>
|
||||
<constraints>
|
||||
<constraint firstAttribute="width" constant="200" id="Qin-jm-4zt"/>
|
||||
</constraints>
|
||||
<textFieldCell key="cell" scrollable="YES" lineBreakMode="clipping" selectable="YES" editable="YES" sendsActionOnEndEditing="YES" borderStyle="bezel" drawsBackground="YES" id="fCk-Tf-q01">
|
||||
<font key="font" metaFont="system"/>
|
||||
<color key="textColor" name="controlTextColor" catalog="System" colorSpace="catalog"/>
|
||||
<color key="backgroundColor" name="textBackgroundColor" catalog="System" colorSpace="catalog"/>
|
||||
</textFieldCell>
|
||||
</textField>
|
||||
</gridCell>
|
||||
<gridCell row="eW8-uH-txq" column="fCQ-jY-Mts" id="Hqa-3w-cQv">
|
||||
<textField key="contentView" horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="wEx-TM-rPM">
|
||||
<rect key="frame" x="54" y="47" width="66" height="16"/>
|
||||
<textFieldCell key="cell" lineBreakMode="clipping" title="Password:" id="7g8-Kk-ISg">
|
||||
<font key="font" metaFont="system"/>
|
||||
<color key="textColor" name="labelColor" catalog="System" colorSpace="catalog"/>
|
||||
<color key="backgroundColor" name="textBackgroundColor" catalog="System" colorSpace="catalog"/>
|
||||
</textFieldCell>
|
||||
</textField>
|
||||
</gridCell>
|
||||
<gridCell row="eW8-uH-txq" column="7CY-bX-6x4" id="m16-3v-9pf">
|
||||
<secureTextField key="contentView" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="JSa-LY-zNQ">
|
||||
<rect key="frame" x="132" y="44" width="200" height="21"/>
|
||||
<constraints>
|
||||
<constraint firstAttribute="width" constant="200" id="eal-wa-1nU"/>
|
||||
</constraints>
|
||||
<secureTextFieldCell key="cell" scrollable="YES" lineBreakMode="clipping" selectable="YES" editable="YES" sendsActionOnEndEditing="YES" borderStyle="bezel" drawsBackground="YES" usesSingleLineMode="YES" id="trK-OG-tBe">
|
||||
<font key="font" metaFont="system"/>
|
||||
<color key="textColor" name="controlTextColor" catalog="System" colorSpace="catalog"/>
|
||||
<color key="backgroundColor" name="textBackgroundColor" catalog="System" colorSpace="catalog"/>
|
||||
<allowedInputSourceLocales>
|
||||
<string>NSAllRomanInputSourcesLocaleIdentifier</string>
|
||||
</allowedInputSourceLocales>
|
||||
</secureTextFieldCell>
|
||||
</secureTextField>
|
||||
</gridCell>
|
||||
<gridCell row="DbI-7g-Xme" column="fCQ-jY-Mts" headOfMergedCell="xX0-vn-AId" xPlacement="leading" id="xX0-vn-AId">
|
||||
<textField key="contentView" horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="byK-Sd-r7F">
|
||||
<rect key="frame" x="-2" y="8" width="104" height="16"/>
|
||||
<textFieldCell key="cell" lineBreakMode="clipping" id="0yh-Ab-UTX">
|
||||
<font key="font" usesAppearanceFont="YES"/>
|
||||
<color key="textColor" name="labelColor" catalog="System" colorSpace="catalog"/>
|
||||
<color key="backgroundColor" name="textBackgroundColor" catalog="System" colorSpace="catalog"/>
|
||||
</textFieldCell>
|
||||
</textField>
|
||||
</gridCell>
|
||||
<gridCell row="DbI-7g-Xme" column="7CY-bX-6x4" headOfMergedCell="xX0-vn-AId" id="hk5-St-E4y"/>
|
||||
</gridCells>
|
||||
</gridView>
|
||||
<button verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="9mz-D9-krh">
|
||||
<rect key="frame" x="340" y="13" width="79" height="32"/>
|
||||
<buttonCell key="cell" type="push" title="Action" bezelStyle="rounded" alignment="center" borderStyle="border" imageScaling="proportionallyDown" inset="2" id="IMO-YT-k9Z">
|
||||
<behavior key="behavior" pushIn="YES" lightByBackground="YES" lightByGray="YES"/>
|
||||
<font key="font" metaFont="system"/>
|
||||
<string key="keyEquivalent" base64-UTF8="YES">
|
||||
DQ
|
||||
</string>
|
||||
</buttonCell>
|
||||
<connections>
|
||||
<action selector="action:" target="-2" id="Kix-5a-5Og"/>
|
||||
</connections>
|
||||
</button>
|
||||
<button verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="XAM-Hb-0Hw">
|
||||
<rect key="frame" x="258" y="13" width="82" height="32"/>
|
||||
<buttonCell key="cell" type="push" title="Cancel" bezelStyle="rounded" alignment="center" borderStyle="border" imageScaling="proportionallyDown" inset="2" id="ufs-ar-BAY">
|
||||
<behavior key="behavior" pushIn="YES" lightByBackground="YES" lightByGray="YES"/>
|
||||
<font key="font" metaFont="system"/>
|
||||
<string key="keyEquivalent" base64-UTF8="YES">
|
||||
Gw
|
||||
</string>
|
||||
</buttonCell>
|
||||
<connections>
|
||||
<action selector="cancel:" target="-2" id="WAD-ES-hpq"/>
|
||||
</connections>
|
||||
</button>
|
||||
<progressIndicator hidden="YES" wantsLayer="YES" horizontalHuggingPriority="750" verticalHuggingPriority="750" maxValue="100" displayedWhenStopped="NO" bezeled="NO" indeterminate="YES" controlSize="small" style="spinning" translatesAutoresizingMaskIntoConstraints="NO" id="B0W-bh-Evv">
|
||||
<rect key="frame" x="209" y="167" width="16" height="16"/>
|
||||
</progressIndicator>
|
||||
</subviews>
|
||||
<constraints>
|
||||
<constraint firstItem="9mz-D9-krh" firstAttribute="leading" secondItem="XAM-Hb-0Hw" secondAttribute="trailing" constant="12" symbolic="YES" id="CC8-HR-FDy"/>
|
||||
<constraint firstItem="XAM-Hb-0Hw" firstAttribute="centerY" secondItem="9mz-D9-krh" secondAttribute="centerY" id="M2M-fb-kfR"/>
|
||||
<constraint firstAttribute="bottom" secondItem="9mz-D9-krh" secondAttribute="bottom" constant="20" id="PK2-Ye-400"/>
|
||||
<constraint firstItem="zBB-JH-huI" firstAttribute="top" secondItem="B0W-bh-Evv" secondAttribute="bottom" constant="8" id="V7z-a7-OOG"/>
|
||||
<constraint firstItem="9mz-D9-krh" firstAttribute="top" secondItem="zBB-JH-huI" secondAttribute="bottom" constant="20" symbolic="YES" id="Wu3-hp-Vzh"/>
|
||||
<constraint firstItem="zBB-JH-huI" firstAttribute="centerX" secondItem="se5-gp-TjO" secondAttribute="centerX" id="aFI-4s-mMv"/>
|
||||
<constraint firstAttribute="trailing" secondItem="9mz-D9-krh" secondAttribute="trailing" constant="20" id="fVQ-zN-rKd"/>
|
||||
<constraint firstItem="B0W-bh-Evv" firstAttribute="top" secondItem="lti-yM-8LV" secondAttribute="bottom" constant="8" id="gq2-tB-pXH"/>
|
||||
<constraint firstItem="7Ht-Fn-0Ya" firstAttribute="top" secondItem="se5-gp-TjO" secondAttribute="top" constant="20" id="jlY-Jg-KJR"/>
|
||||
<constraint firstItem="B0W-bh-Evv" firstAttribute="centerX" secondItem="se5-gp-TjO" secondAttribute="centerX" id="lrN-Gd-iXd"/>
|
||||
<constraint firstItem="7Ht-Fn-0Ya" firstAttribute="centerX" secondItem="se5-gp-TjO" secondAttribute="centerX" id="tAZ-Te-w3H"/>
|
||||
</constraints>
|
||||
</view>
|
||||
<connections>
|
||||
<outlet property="delegate" destination="-2" id="0bl-1N-AYu"/>
|
||||
</connections>
|
||||
<point key="canvasLocation" x="116.5" y="136.5"/>
|
||||
</window>
|
||||
</objects>
|
||||
<resources>
|
||||
<image name="accountNewsBlur" width="512" height="512"/>
|
||||
</resources>
|
||||
</document>
|
|
@ -0,0 +1,110 @@
|
|||
//
|
||||
// AccountsNewsBlurWindowController.swift
|
||||
// NetNewsWire
|
||||
//
|
||||
// Created by Anh Quang Do on 2020-03-22.
|
||||
// Copyright (c) 2020 Ranchero Software. All rights reserved.
|
||||
//
|
||||
|
||||
import AppKit
|
||||
import Account
|
||||
import RSWeb
|
||||
|
||||
class AccountsNewsBlurWindowController: NSWindowController {
|
||||
@IBOutlet weak var progressIndicator: NSProgressIndicator!
|
||||
@IBOutlet weak var usernameTextField: NSTextField!
|
||||
@IBOutlet weak var passwordTextField: NSSecureTextField!
|
||||
@IBOutlet weak var errorMessageLabel: NSTextField!
|
||||
@IBOutlet weak var actionButton: NSButton!
|
||||
|
||||
var account: Account?
|
||||
|
||||
private weak var hostWindow: NSWindow?
|
||||
|
||||
convenience init() {
|
||||
self.init(windowNibName: NSNib.Name("AccountsNewsBlur"))
|
||||
}
|
||||
|
||||
override func windowDidLoad() {
|
||||
if let account = account, let credentials = try? account.retrieveCredentials(type: .newsBlurBasic) {
|
||||
usernameTextField.stringValue = credentials.username
|
||||
actionButton.title = NSLocalizedString("Update", comment: "Update")
|
||||
} else {
|
||||
actionButton.title = NSLocalizedString("Create", comment: "Create")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: API
|
||||
|
||||
func runSheetOnWindow(_ hostWindow: NSWindow, completion: ((NSApplication.ModalResponse) -> Void)? = nil) {
|
||||
self.hostWindow = hostWindow
|
||||
hostWindow.beginSheet(window!, completionHandler: completion)
|
||||
}
|
||||
|
||||
// MARK: Actions
|
||||
|
||||
@IBAction func cancel(_ sender: Any) {
|
||||
hostWindow!.endSheet(window!, returnCode: NSApplication.ModalResponse.cancel)
|
||||
}
|
||||
|
||||
@IBAction func action(_ sender: Any) {
|
||||
self.errorMessageLabel.stringValue = ""
|
||||
|
||||
guard !usernameTextField.stringValue.isEmpty else {
|
||||
self.errorMessageLabel.stringValue = NSLocalizedString("Username required.", comment: "Credentials Error")
|
||||
return
|
||||
}
|
||||
|
||||
actionButton.isEnabled = false
|
||||
progressIndicator.isHidden = false
|
||||
progressIndicator.startAnimation(self)
|
||||
|
||||
let credentials = Credentials(type: .newsBlurBasic, username: usernameTextField.stringValue, secret: passwordTextField.stringValue)
|
||||
Account.validateCredentials(type: .newsBlur, credentials: credentials) { [weak self] result in
|
||||
|
||||
guard let self = self else { return }
|
||||
|
||||
self.actionButton.isEnabled = true
|
||||
self.progressIndicator.isHidden = true
|
||||
self.progressIndicator.stopAnimation(self)
|
||||
|
||||
switch result {
|
||||
case .success(let validatedCredentials):
|
||||
guard let validatedCredentials = validatedCredentials else {
|
||||
self.errorMessageLabel.stringValue = NSLocalizedString("Invalid email/password combination.", comment: "Credentials Error")
|
||||
return
|
||||
}
|
||||
var newAccount = false
|
||||
if self.account == nil {
|
||||
self.account = AccountManager.shared.createAccount(type: .newsBlur)
|
||||
newAccount = true
|
||||
}
|
||||
|
||||
do {
|
||||
try self.account?.removeCredentials(type: .newsBlurBasic)
|
||||
try self.account?.removeCredentials(type: .newsBlurSessionId)
|
||||
try self.account?.storeCredentials(credentials)
|
||||
try self.account?.storeCredentials(validatedCredentials)
|
||||
if newAccount {
|
||||
self.account?.refreshAll() { result in
|
||||
switch result {
|
||||
case .success:
|
||||
break
|
||||
case .failure(let error):
|
||||
NSApplication.shared.presentError(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
self.hostWindow?.endSheet(self.window!, returnCode: NSApplication.ModalResponse.OK)
|
||||
} catch {
|
||||
self.errorMessageLabel.stringValue = NSLocalizedString("Keychain error while storing credentials.", comment: "Credentials Error")
|
||||
}
|
||||
|
||||
case .failure:
|
||||
|
||||
self.errorMessageLabel.stringValue = NSLocalizedString("Network error. Try again later.", comment: "Credentials Error")
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,15 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"filename" : "newsblur-512.png"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"version" : 1,
|
||||
"author" : "xcode"
|
||||
},
|
||||
"properties" : {
|
||||
"template-rendering-intent" : "template"
|
||||
}
|
||||
}
|
Binary file not shown.
After Width: | Height: | Size: 53 KiB |
|
@ -7,6 +7,8 @@
|
|||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
179DB1DFBCF9177104B12E0F /* AccountsNewsBlurWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 179DBBA2B22A659F81EED6F9 /* AccountsNewsBlurWindowController.swift */; };
|
||||
179DB3CE822BFCC2D774D9F4 /* AccountsNewsBlurWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 179DBBA2B22A659F81EED6F9 /* AccountsNewsBlurWindowController.swift */; };
|
||||
3B3A32A5238B820900314204 /* FeedWranglerAccountViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B3A328B238B820900314204 /* FeedWranglerAccountViewController.swift */; };
|
||||
3B826DCB2385C84800FC1ADB /* AccountsFeedWrangler.xib in Resources */ = {isa = PBXBuildFile; fileRef = 3B826DB02385C84800FC1ADB /* AccountsFeedWrangler.xib */; };
|
||||
3B826DCC2385C84800FC1ADB /* AccountsFeedWranglerWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B826DCA2385C84800FC1ADB /* AccountsFeedWranglerWindowController.swift */; };
|
||||
|
@ -45,6 +47,7 @@
|
|||
512AF9C2236ED52C0066F8BE /* ImageHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 512AF9C1236ED52C0066F8BE /* ImageHeaderView.swift */; };
|
||||
512AF9DD236F05230066F8BE /* InteractiveLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 512AF9DC236F05230066F8BE /* InteractiveLabel.swift */; };
|
||||
512D554423C804DE0023FFFA /* OpenInSafariActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 512D554323C804DE0023FFFA /* OpenInSafariActivity.swift */; };
|
||||
512DD4C92430086400C17B1F /* CloudKitAccountViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 512DD4C82430086400C17B1F /* CloudKitAccountViewController.swift */; };
|
||||
512E08E62268800D00BDCFDD /* FolderTreeControllerDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 849A97A11ED9F180007D329B /* FolderTreeControllerDelegate.swift */; };
|
||||
512E08E72268801200BDCFDD /* WebFeedTreeControllerDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 849A97611ED9EB96007D329B /* WebFeedTreeControllerDelegate.swift */; };
|
||||
512E09012268907400BDCFDD /* MasterFeedTableViewSectionHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 512E08F722688F7C00BDCFDD /* MasterFeedTableViewSectionHeader.swift */; };
|
||||
|
@ -665,6 +668,8 @@
|
|||
B2B80778239C4C7000F191E0 /* RSImage-AppIcons.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2B8075D239C49D300F191E0 /* RSImage-AppIcons.swift */; };
|
||||
B2B80779239C4C7300F191E0 /* RSImage-AppIcons.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2B8075D239C49D300F191E0 /* RSImage-AppIcons.swift */; };
|
||||
B528F81E23333C7E00E735DD /* page.html in Resources */ = {isa = PBXBuildFile; fileRef = B528F81D23333C7E00E735DD /* page.html */; };
|
||||
BDCB516724282C8A00102A80 /* AccountsNewsBlur.xib in Resources */ = {isa = PBXBuildFile; fileRef = BDCB514D24282C8A00102A80 /* AccountsNewsBlur.xib */; };
|
||||
BDCB516824282C8A00102A80 /* AccountsNewsBlur.xib in Resources */ = {isa = PBXBuildFile; fileRef = BDCB514D24282C8A00102A80 /* AccountsNewsBlur.xib */; };
|
||||
C5A6ED5223C9AF4300AB6BE2 /* TitleActivityItemSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = C5A6ED5123C9AF4300AB6BE2 /* TitleActivityItemSource.swift */; };
|
||||
C5A6ED6D23C9B0C800AB6BE2 /* UIActivityViewController-Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = C5A6ED6C23C9B0C800AB6BE2 /* UIActivityViewController-Extensions.swift */; };
|
||||
D553738B20186C20006D8857 /* Article+Scriptability.swift in Sources */ = {isa = PBXBuildFile; fileRef = D553737C20186C1F006D8857 /* Article+Scriptability.swift */; };
|
||||
|
@ -1255,6 +1260,7 @@
|
|||
/* End PBXCopyFilesBuildPhase section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
179DBBA2B22A659F81EED6F9 /* AccountsNewsBlurWindowController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AccountsNewsBlurWindowController.swift; sourceTree = "<group>"; };
|
||||
3B3A328B238B820900314204 /* FeedWranglerAccountViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FeedWranglerAccountViewController.swift; sourceTree = "<group>"; };
|
||||
3B826DB02385C84800FC1ADB /* AccountsFeedWrangler.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = AccountsFeedWrangler.xib; sourceTree = "<group>"; };
|
||||
3B826DCA2385C84800FC1ADB /* AccountsFeedWranglerWindowController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AccountsFeedWranglerWindowController.swift; sourceTree = "<group>"; };
|
||||
|
@ -1282,6 +1288,7 @@
|
|||
512AF9C1236ED52C0066F8BE /* ImageHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageHeaderView.swift; sourceTree = "<group>"; };
|
||||
512AF9DC236F05230066F8BE /* InteractiveLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InteractiveLabel.swift; sourceTree = "<group>"; };
|
||||
512D554323C804DE0023FFFA /* OpenInSafariActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenInSafariActivity.swift; sourceTree = "<group>"; };
|
||||
512DD4C82430086400C17B1F /* CloudKitAccountViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudKitAccountViewController.swift; sourceTree = "<group>"; };
|
||||
512E08F722688F7C00BDCFDD /* MasterFeedTableViewSectionHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MasterFeedTableViewSectionHeader.swift; sourceTree = "<group>"; };
|
||||
51314617235A797400387FDC /* NetNewsWire_iOSintentextension_target.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = NetNewsWire_iOSintentextension_target.xcconfig; sourceTree = "<group>"; };
|
||||
51314637235A7BBE00387FDC /* NetNewsWire iOS Intents Extension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "NetNewsWire iOS Intents Extension.appex"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
|
@ -1626,6 +1633,7 @@
|
|||
B24EFD5923310109006C6242 /* WKPreferencesPrivate.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = WKPreferencesPrivate.h; sourceTree = "<group>"; };
|
||||
B2B8075D239C49D300F191E0 /* RSImage-AppIcons.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RSImage-AppIcons.swift"; sourceTree = "<group>"; };
|
||||
B528F81D23333C7E00E735DD /* page.html */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.html; path = page.html; sourceTree = "<group>"; };
|
||||
BDCB514D24282C8A00102A80 /* AccountsNewsBlur.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = AccountsNewsBlur.xib; sourceTree = "<group>"; };
|
||||
C5A6ED5123C9AF4300AB6BE2 /* TitleActivityItemSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TitleActivityItemSource.swift; sourceTree = "<group>"; };
|
||||
C5A6ED6C23C9B0C800AB6BE2 /* UIActivityViewController-Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIActivityViewController-Extensions.swift"; sourceTree = "<group>"; };
|
||||
D519E74722EE553300923F27 /* NetNewsWire_safariextension_target.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = NetNewsWire_safariextension_target.xcconfig; sourceTree = "<group>"; };
|
||||
|
@ -1875,6 +1883,7 @@
|
|||
children = (
|
||||
516A093F2361240900EAE89B /* Account.storyboard */,
|
||||
51A1698F235E10D600EB091F /* LocalAccountViewController.swift */,
|
||||
512DD4C82430086400C17B1F /* CloudKitAccountViewController.swift */,
|
||||
51A16996235E10D700EB091F /* FeedbinAccountViewController.swift */,
|
||||
3B3A328B238B820900314204 /* FeedWranglerAccountViewController.swift */,
|
||||
769F2D3643779DB02786278E /* NewsBlurAccountViewController.swift */,
|
||||
|
@ -2630,6 +2639,8 @@
|
|||
5144EA4F227B8E4500D19003 /* AccountsFeedbinWindowController.swift */,
|
||||
3B826DB02385C84800FC1ADB /* AccountsFeedWrangler.xib */,
|
||||
3B826DCA2385C84800FC1ADB /* AccountsFeedWranglerWindowController.swift */,
|
||||
BDCB514D24282C8A00102A80 /* AccountsNewsBlur.xib */,
|
||||
179DBBA2B22A659F81EED6F9 /* AccountsNewsBlurWindowController.swift */,
|
||||
55E15BC1229D65A900D6602A /* AccountsReaderAPI.xib */,
|
||||
55E15BCA229D65A900D6602A /* AccountsReaderAPIWindowController.swift */,
|
||||
5144EA352279FC3D00D19003 /* AccountsAddLocal.xib */,
|
||||
|
@ -3501,6 +3512,7 @@
|
|||
65ED4066235DEF6C0081F399 /* TimelineTableView.xib in Resources */,
|
||||
65ED4067235DEF6C0081F399 /* page.html in Resources */,
|
||||
65ED4068235DEF6C0081F399 /* MainWindow.storyboard in Resources */,
|
||||
BDCB516824282C8A00102A80 /* AccountsNewsBlur.xib in Resources */,
|
||||
3B826DCD2385C89600FC1ADB /* AccountsFeedWrangler.xib in Resources */,
|
||||
65ED4069235DEF6C0081F399 /* AccountsReaderAPI.xib in Resources */,
|
||||
65ED406A235DEF6C0081F399 /* newsfoot.js in Resources */,
|
||||
|
@ -3593,6 +3605,7 @@
|
|||
3B826DCB2385C84800FC1ADB /* AccountsFeedWrangler.xib in Resources */,
|
||||
55E15BCB229D65A900D6602A /* AccountsReaderAPI.xib in Resources */,
|
||||
49F40DF82335B71000552BF4 /* newsfoot.js in Resources */,
|
||||
BDCB516724282C8A00102A80 /* AccountsNewsBlur.xib in Resources */,
|
||||
5103A9982421643300410853 /* blank.html in Resources */,
|
||||
84BAE64921CEDAF20046DB56 /* CrashReporterWindow.xib in Resources */,
|
||||
84C9FC8E22629E8F00D921D6 /* Credits.rtf in Resources */,
|
||||
|
@ -3627,7 +3640,7 @@
|
|||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "xcrun -sdk macosx swiftc -target x86_64-macosx10.11 buildscripts/VerifyNoBuildSettings.swift -o $CONFIGURATION_TEMP_DIR/VerifyNoBS\n$CONFIGURATION_TEMP_DIR/VerifyNoBS ${PROJECT_NAME}.xcodeproj/project.pbxproj\n";
|
||||
shellScript = "xcrun -sdk macosx swiftc -target x86_64-macosx10.11 buildscripts/VerifyNoBuildSettings.swift -o $CONFIGURATION_TEMP_DIR/VerifyNoBS\n$CONFIGURATION_TEMP_DIR/VerifyNoBS ${PROJECT_NAME}.xcodeproj/project.pbxproj\n\n\nif [ $? -ne 0 ]\nthen\n echo \"error: Build Setting were found in the project.pbxproj file. Most likely you didn't intend to change this file and should revert it.\"\n exit 1\nfi\n";
|
||||
};
|
||||
65ED406F235DEF6C0081F399 /* Run Script: Automated build numbers */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
|
@ -3659,7 +3672,7 @@
|
|||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "xcrun -sdk macosx swiftc -target x86_64-macosx10.11 buildscripts/VerifyNoBuildSettings.swift -o $CONFIGURATION_TEMP_DIR/VerifyNoBS\n$CONFIGURATION_TEMP_DIR/VerifyNoBS ${PROJECT_NAME}.xcodeproj/project.pbxproj\n";
|
||||
shellScript = "xcrun -sdk macosx swiftc -target x86_64-macosx10.11 buildscripts/VerifyNoBuildSettings.swift -o $CONFIGURATION_TEMP_DIR/VerifyNoBS\n$CONFIGURATION_TEMP_DIR/VerifyNoBS ${PROJECT_NAME}.xcodeproj/project.pbxproj\n\nif [ $? -ne 0 ]\nthen\n echo \"error: Build Setting were found in the project.pbxproj file. Most likely you didn't intend to change this file and should revert it.\"\n exit 1\nfi\n";
|
||||
};
|
||||
8423E3E3220158E700C3795B /* Run Script: Code Sign Sparkle */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
|
@ -3709,7 +3722,7 @@
|
|||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "xcrun -sdk macosx swiftc -target x86_64-macosx10.11 buildscripts/VerifyNoBuildSettings.swift -o $CONFIGURATION_TEMP_DIR/VerifyNoBS\n$CONFIGURATION_TEMP_DIR/VerifyNoBS ${PROJECT_NAME}.xcodeproj/project.pbxproj\n";
|
||||
shellScript = "xcrun -sdk macosx swiftc -target x86_64-macosx10.11 buildscripts/VerifyNoBuildSettings.swift -o $CONFIGURATION_TEMP_DIR/VerifyNoBS\n$CONFIGURATION_TEMP_DIR/VerifyNoBS ${PROJECT_NAME}.xcodeproj/project.pbxproj\n\n\nif [ $? -ne 0 ]\nthen\n echo \"error: Build Setting were found in the project.pbxproj file. Most likely you didn't intend to change this file and should revert it.\"\n exit 1\nfi\n";
|
||||
};
|
||||
/* End PBXShellScriptBuildPhase section */
|
||||
|
||||
|
@ -3909,6 +3922,7 @@
|
|||
65ED403E235DEF6C0081F399 /* TimelineCellAppearance.swift in Sources */,
|
||||
65ED403F235DEF6C0081F399 /* ArticleRenderer.swift in Sources */,
|
||||
65ED4040235DEF6C0081F399 /* GeneralPrefencesViewController.swift in Sources */,
|
||||
179DB1DFBCF9177104B12E0F /* AccountsNewsBlurWindowController.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
|
@ -3926,6 +3940,7 @@
|
|||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
51E36E71239D6610006F47A5 /* AddWebFeedSelectFolderTableViewCell.swift in Sources */,
|
||||
512DD4C92430086400C17B1F /* CloudKitAccountViewController.swift in Sources */,
|
||||
840D617F2029031C009BC708 /* AppDelegate.swift in Sources */,
|
||||
51236339236915B100951F16 /* RoundedProgressView.swift in Sources */,
|
||||
512E08E72268801200BDCFDD /* WebFeedTreeControllerDelegate.swift in Sources */,
|
||||
|
@ -4226,6 +4241,7 @@
|
|||
849A97761ED9EC04007D329B /* TimelineCellAppearance.swift in Sources */,
|
||||
849A977F1ED9EC42007D329B /* ArticleRenderer.swift in Sources */,
|
||||
84C9FC7822629E1200D921D6 /* GeneralPrefencesViewController.swift in Sources */,
|
||||
179DB3CE822BFCC2D774D9F4 /* AccountsNewsBlurWindowController.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
|
|
32
README.md
32
README.md
|
@ -40,8 +40,9 @@ This allows for a pristine project with code signing set up with the appropriate
|
|||
developer ID and certificates, and for dev to be able to have local settings
|
||||
without needing to check in anything into source control.
|
||||
|
||||
As an example, make a directory SharedXcodeSettings next to where you have this repository.
|
||||
An example of the structure is:
|
||||
Make a directory SharedXcodeSettings next to where you have this repository.
|
||||
|
||||
The directory structure is:
|
||||
|
||||
```
|
||||
aDirectory/
|
||||
|
@ -50,6 +51,13 @@ aDirectory/
|
|||
NetNewsWire
|
||||
NetNewsWire.xcworkspace
|
||||
```
|
||||
Example:
|
||||
|
||||
If your NetNewsWire Xcode project file is at:
|
||||
`/Users/Shared/git/NetNewsWire/NetNewsWire.xcodeproj`
|
||||
|
||||
Create your `DeveloperSettings.xcconfig` file at
|
||||
`/Users/Shared/git/SharedXcodeSettings/DeveloperSettings.xcconfig`
|
||||
|
||||
Then create a plain text file in it: `SharedXcodeSettings/DeveloperSettings.xcconfig` and
|
||||
give it the contents:
|
||||
|
@ -63,13 +71,17 @@ DEVELOPER_ENTITLEMENTS = -dev
|
|||
PROVISIONING_PROFILE_SPECIFIER =
|
||||
```
|
||||
|
||||
Set `DEVELOPMENT_TEAM` to your Apple supplied development team. You can use Keychain
|
||||
Acceass to [find your development team ID](/Technotes/FindingYourDevelopmentTeamID.md).
|
||||
Set `ORGANIZATION_IDENTIFIER` to a reversed domain name that you control or have made up.
|
||||
Note that `PROVISIONING_PROFILE_SPECIFIER` should not have a value associated with it.
|
||||
|
||||
You can now open the `NetNewsWire.xcworkspace` in Xcode.
|
||||
|
||||
Now you should be able to build without code signing errors and without modifying
|
||||
the NetNewsWire Xcode project.
|
||||
the NetNewsWire Xcode project. This is a special build of NetNewsWire with some
|
||||
functionality disabled. This is because we have API keys that can't be stored in the
|
||||
repository or shared between developers. Certain account types, like Feedly, aren't
|
||||
enabled and the Reader View isn't enabled because of this.
|
||||
|
||||
Example:
|
||||
|
||||
If your NetNewsWire Xcode project file is at:
|
||||
`/Users/Shared/git/NetNewsWire/NetNewsWire.xcodeproj`
|
||||
|
||||
Create your `DeveloperSettings.xcconfig` file at
|
||||
`/Users/Shared/git/SharedXcodeSettings/DeveloperSettings.xcconfig`
|
||||
If you have any problems, we will help you out in Slack (see above).
|
||||
|
|
|
@ -14,7 +14,7 @@ struct AddWebFeedDefaultContainer {
|
|||
static var defaultContainer: Container? {
|
||||
|
||||
if let accountID = AppDefaults.addWebFeedAccountID, let account = AccountManager.shared.activeAccounts.first(where: { $0.accountID == accountID }) {
|
||||
if let folderName = AppDefaults.addWebFeedFolderName, let folder = account.findFolder(withDisplayName: folderName) {
|
||||
if let folderName = AppDefaults.addWebFeedFolderName, let folder = account.existingFolder(withDisplayName: folderName) {
|
||||
return folder
|
||||
} else {
|
||||
return substituteContainerIfNeeded(account: account)
|
||||
|
|
|
@ -27,11 +27,10 @@ extension RSImage {
|
|||
}
|
||||
|
||||
extension IconImage {
|
||||
static var appIcon: IconImage? {
|
||||
static var appIcon: IconImage? = {
|
||||
if let image = RSImage.appIconImage {
|
||||
return IconImage(image)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
|
|
@ -67,6 +67,10 @@ public final class WebFeedIconDownloader {
|
|||
return cachedImage
|
||||
}
|
||||
|
||||
if let hpURLString = feed.homePageURL, let hpURL = URL(string: hpURLString), hpURL.host == "nnw.ranchero.com" {
|
||||
return IconImage.appIcon
|
||||
}
|
||||
|
||||
func checkHomePageURL() {
|
||||
guard let homePageURL = feed.homePageURL else {
|
||||
return
|
||||
|
@ -124,11 +128,6 @@ private extension WebFeedIconDownloader {
|
|||
|
||||
func icon(forHomePageURL homePageURL: String, feed: WebFeed, _ imageResultBlock: @escaping (RSImage?) -> Void) {
|
||||
|
||||
if let url = URL(string: homePageURL), url.host == "nnw.ranchero.com" {
|
||||
imageResultBlock(RSImage.appIconImage)
|
||||
return
|
||||
}
|
||||
|
||||
if homePagesWithNoIconURLCache.contains(homePageURL) || homePagesWithUglyIcons.contains(homePageURL) {
|
||||
imageResultBlock(nil)
|
||||
return
|
||||
|
|
|
@ -0,0 +1,13 @@
|
|||
# Finding Your Development Team ID
|
||||
|
||||
* Open Keychain Access on your development machine.
|
||||
* On the left hand side, make sure "My Certificates" is selected.
|
||||
* Find the certificate that reads `Apple Development: <Your Name>`
|
||||
|
||||
![DevelopmentTeamID](Images/DevelopmentTeamID.png)
|
||||
|
||||
* Right click on the certificate and select "Get Info".
|
||||
|
||||
![DevelopmentTeamIDInfo](Images/DevelopmentTeamIDInfo.png)
|
||||
|
||||
Your **Development Team ID** is the value next to **Organizational Unit**.
|
Binary file not shown.
After Width: | Height: | Size: 63 KiB |
Binary file not shown.
After Width: | Height: | Size: 97 KiB |
|
@ -1,8 +1,8 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="15705" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
|
||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="16096" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
|
||||
<device id="retina6_1" orientation="portrait" appearance="light"/>
|
||||
<dependencies>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="15706"/>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="16086"/>
|
||||
<capability name="Named colors" minToolsVersion="9.0"/>
|
||||
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
|
||||
</dependencies>
|
||||
|
@ -10,7 +10,7 @@
|
|||
<!--Modal Navigation Controller-->
|
||||
<scene sceneID="98f-PW-S1C">
|
||||
<objects>
|
||||
<navigationController storyboardIdentifier="AddLocalAccountNavigationViewController" id="TMY-HB-vAu" customClass="ModalNavigationController" customModule="NetNewsWire" customModuleProvider="target" sceneMemberID="viewController">
|
||||
<navigationController storyboardIdentifier="LocalAccountNavigationViewController" id="TMY-HB-vAu" customClass="ModalNavigationController" customModule="NetNewsWire" customModuleProvider="target" sceneMemberID="viewController">
|
||||
<navigationBar key="navigationBar" contentMode="scaleToFill" insetsLayoutMarginsFromSafeArea="NO" id="p8g-7e-3f4">
|
||||
<rect key="frame" x="0.0" y="44" width="414" height="44"/>
|
||||
<autoresizingMask key="autoresizingMask"/>
|
||||
|
@ -21,7 +21,7 @@
|
|||
</navigationController>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="6sV-68-OXu" userLabel="First Responder" sceneMemberID="firstResponder"/>
|
||||
</objects>
|
||||
<point key="canvasLocation" x="2506" y="-528"/>
|
||||
<point key="canvasLocation" x="1880" y="-528"/>
|
||||
</scene>
|
||||
<!--Modal Navigation Controller-->
|
||||
<scene sceneID="6i4-ho-e4F">
|
||||
|
@ -57,7 +57,7 @@
|
|||
<autoresizingMask key="autoresizingMask"/>
|
||||
<subviews>
|
||||
<textField opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="left" contentVerticalAlignment="center" placeholder="Email" textAlignment="natural" adjustsFontForContentSizeCategory="YES" minimumFontSize="17" translatesAutoresizingMaskIntoConstraints="NO" id="o06-fe-i3S">
|
||||
<rect key="frame" x="20" y="11.5" width="334" height="21"/>
|
||||
<rect key="frame" x="20" y="11" width="334" height="22"/>
|
||||
<fontDescription key="fontDescription" style="UICTFontTextStyleBody"/>
|
||||
<textInputTraits key="textInputTraits" spellCheckingType="no" keyboardType="emailAddress" textContentType="username"/>
|
||||
</textField>
|
||||
|
@ -206,7 +206,7 @@
|
|||
<autoresizingMask key="autoresizingMask"/>
|
||||
<subviews>
|
||||
<textField opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="left" contentVerticalAlignment="center" placeholder="Name" textAlignment="natural" adjustsFontForContentSizeCategory="YES" minimumFontSize="17" translatesAutoresizingMaskIntoConstraints="NO" id="Yl1-R6-xZi">
|
||||
<rect key="frame" x="20" y="11.5" width="334" height="21"/>
|
||||
<rect key="frame" x="20" y="11" width="334" height="22"/>
|
||||
<fontDescription key="fontDescription" style="UICTFontTextStyleBody"/>
|
||||
<textInputTraits key="textInputTraits" autocapitalizationType="words"/>
|
||||
</textField>
|
||||
|
@ -271,7 +271,7 @@
|
|||
</tableViewController>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="XJD-sO-MSq" userLabel="First Responder" customClass="UIResponder" sceneMemberID="firstResponder"/>
|
||||
</objects>
|
||||
<point key="canvasLocation" x="2505.7971014492755" y="144.64285714285714"/>
|
||||
<point key="canvasLocation" x="1880" y="145"/>
|
||||
</scene>
|
||||
<!--Feedbin-->
|
||||
<scene sceneID="IDj-HA-olN">
|
||||
|
@ -291,7 +291,7 @@
|
|||
<autoresizingMask key="autoresizingMask"/>
|
||||
<subviews>
|
||||
<textField opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="left" contentVerticalAlignment="center" placeholder="Email" textAlignment="natural" adjustsFontForContentSizeCategory="YES" minimumFontSize="17" translatesAutoresizingMaskIntoConstraints="NO" id="vJa-NN-yjR">
|
||||
<rect key="frame" x="20" y="11.5" width="334" height="21"/>
|
||||
<rect key="frame" x="20" y="11" width="334" height="22"/>
|
||||
<fontDescription key="fontDescription" style="UICTFontTextStyleBody"/>
|
||||
<textInputTraits key="textInputTraits" spellCheckingType="no" keyboardType="emailAddress" textContentType="username"/>
|
||||
</textField>
|
||||
|
@ -315,7 +315,7 @@
|
|||
<fontDescription key="fontDescription" style="UICTFontTextStyleBody"/>
|
||||
<textInputTraits key="textInputTraits" secureTextEntry="YES" textContentType="password"/>
|
||||
</textField>
|
||||
<button opaque="NO" contentMode="scaleToFill" horizontalHuggingPriority="251" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="roundedRect" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="TfW-wf-V06">
|
||||
<button opaque="NO" contentMode="scaleToFill" horizontalHuggingPriority="251" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="roundedRect" lineBreakMode="middleTruncation" pointerInteraction="YES" translatesAutoresizingMaskIntoConstraints="NO" id="TfW-wf-V06">
|
||||
<rect key="frame" x="311" y="5.5" width="43" height="33"/>
|
||||
<fontDescription key="fontDescription" style="UICTFontTextStyleBody"/>
|
||||
<state key="normal" title="Show"/>
|
||||
|
@ -440,7 +440,7 @@
|
|||
<autoresizingMask key="autoresizingMask"/>
|
||||
<subviews>
|
||||
<textField opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="left" contentVerticalAlignment="center" placeholder="Username or Email" textAlignment="natural" adjustsFontForContentSizeCategory="YES" minimumFontSize="17" translatesAutoresizingMaskIntoConstraints="NO" id="S4v-fs-DIO">
|
||||
<rect key="frame" x="20" y="11.5" width="334" height="21"/>
|
||||
<rect key="frame" x="20" y="11" width="334" height="22"/>
|
||||
<fontDescription key="fontDescription" style="UICTFontTextStyleBody"/>
|
||||
<textInputTraits key="textInputTraits" spellCheckingType="no" keyboardType="emailAddress" textContentType="username"/>
|
||||
</textField>
|
||||
|
@ -555,6 +555,80 @@
|
|||
</objects>
|
||||
<point key="canvasLocation" x="4562" y="145"/>
|
||||
</scene>
|
||||
<!--Modal Navigation Controller-->
|
||||
<scene sceneID="gfi-2F-rht">
|
||||
<objects>
|
||||
<navigationController storyboardIdentifier="CloudKitAccountNavigationViewController" id="LhW-Dq-qqj" customClass="ModalNavigationController" customModule="NetNewsWire" customModuleProvider="target" sceneMemberID="viewController">
|
||||
<navigationBar key="navigationBar" contentMode="scaleToFill" insetsLayoutMarginsFromSafeArea="NO" id="MVG-BZ-ALL">
|
||||
<rect key="frame" x="0.0" y="44" width="414" height="44"/>
|
||||
<autoresizingMask key="autoresizingMask"/>
|
||||
</navigationBar>
|
||||
<connections>
|
||||
<segue destination="qj9-Vr-VIU" kind="relationship" relationship="rootViewController" id="n8n-iF-qeC"/>
|
||||
</connections>
|
||||
</navigationController>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="z9f-5I-8GC" userLabel="First Responder" sceneMemberID="firstResponder"/>
|
||||
</objects>
|
||||
<point key="canvasLocation" x="2533" y="-528"/>
|
||||
</scene>
|
||||
<!--iCloud-->
|
||||
<scene sceneID="ULt-VE-viU">
|
||||
<objects>
|
||||
<tableViewController id="qj9-Vr-VIU" customClass="CloudKitAccountViewController" customModule="NetNewsWire" customModuleProvider="target" sceneMemberID="viewController">
|
||||
<tableView key="view" clipsSubviews="YES" contentMode="scaleToFill" alwaysBounceVertical="YES" dataMode="static" style="insetGrouped" separatorStyle="default" rowHeight="-1" estimatedRowHeight="-1" sectionHeaderHeight="18" sectionFooterHeight="18" id="j6U-sh-M9y">
|
||||
<rect key="frame" x="0.0" y="0.0" width="414" height="896"/>
|
||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||
<sections>
|
||||
<tableViewSection id="bGn-Io-KuQ">
|
||||
<cells>
|
||||
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="none" indentationWidth="10" id="FSY-KL-m3i" customClass="VibrantTableViewCell" customModule="NetNewsWire" customModuleProvider="target">
|
||||
<rect key="frame" x="20" y="18" width="374" height="43.5"/>
|
||||
<autoresizingMask key="autoresizingMask"/>
|
||||
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="FSY-KL-m3i" id="ds7-ib-VgJ">
|
||||
<rect key="frame" x="0.0" y="0.0" width="374" height="43.5"/>
|
||||
<autoresizingMask key="autoresizingMask"/>
|
||||
<subviews>
|
||||
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="T1S-zH-rIp" customClass="VibrantButton" customModule="NetNewsWire" customModuleProvider="target">
|
||||
<rect key="frame" x="0.0" y="0.0" width="374" height="44"/>
|
||||
<constraints>
|
||||
<constraint firstAttribute="height" constant="44" id="dOv-Gz-h7s"/>
|
||||
</constraints>
|
||||
<fontDescription key="fontDescription" style="UICTFontTextStyleBody"/>
|
||||
<state key="normal" title="Add Account">
|
||||
<color key="titleColor" name="secondaryAccentColor"/>
|
||||
</state>
|
||||
<connections>
|
||||
<action selector="add:" destination="qj9-Vr-VIU" eventType="touchUpInside" id="kUm-lW-g62"/>
|
||||
</connections>
|
||||
</button>
|
||||
</subviews>
|
||||
<constraints>
|
||||
<constraint firstItem="T1S-zH-rIp" firstAttribute="leading" secondItem="ds7-ib-VgJ" secondAttribute="leading" id="7F5-Ym-ew3"/>
|
||||
<constraint firstAttribute="trailing" secondItem="T1S-zH-rIp" secondAttribute="trailing" id="ON3-nQ-kd8"/>
|
||||
<constraint firstItem="T1S-zH-rIp" firstAttribute="centerY" secondItem="ds7-ib-VgJ" secondAttribute="centerY" id="dAM-F2-peY"/>
|
||||
</constraints>
|
||||
</tableViewCellContentView>
|
||||
</tableViewCell>
|
||||
</cells>
|
||||
</tableViewSection>
|
||||
</sections>
|
||||
<connections>
|
||||
<outlet property="dataSource" destination="qj9-Vr-VIU" id="j7u-Yd-rbe"/>
|
||||
<outlet property="delegate" destination="qj9-Vr-VIU" id="NhE-Pt-JGp"/>
|
||||
</connections>
|
||||
</tableView>
|
||||
<navigationItem key="navigationItem" title="iCloud" id="idp-kp-cGU">
|
||||
<barButtonItem key="leftBarButtonItem" systemItem="cancel" id="hKZ-OI-mTV">
|
||||
<connections>
|
||||
<action selector="cancel:" destination="qj9-Vr-VIU" id="n5q-9M-3ME"/>
|
||||
</connections>
|
||||
</barButtonItem>
|
||||
</navigationItem>
|
||||
</tableViewController>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="weY-OS-9NV" userLabel="First Responder" customClass="UIResponder" sceneMemberID="firstResponder"/>
|
||||
</objects>
|
||||
<point key="canvasLocation" x="2533" y="145"/>
|
||||
</scene>
|
||||
</scenes>
|
||||
<resources>
|
||||
<namedColor name="secondaryAccentColor">
|
||||
|
|
|
@ -0,0 +1,47 @@
|
|||
//
|
||||
// CloudKitAccountViewController.swift
|
||||
// NetNewsWire-iOS
|
||||
//
|
||||
// Created by Maurice Parker on 3/28/20.
|
||||
// Copyright © 2020 Ranchero Software. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import Account
|
||||
|
||||
class CloudKitAccountViewController: UITableViewController {
|
||||
|
||||
weak var delegate: AddAccountDismissDelegate?
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
tableView.register(ImageHeaderView.self, forHeaderFooterViewReuseIdentifier: "SectionHeader")
|
||||
}
|
||||
|
||||
@IBAction func cancel(_ sender: Any) {
|
||||
dismiss(animated: true, completion: nil)
|
||||
delegate?.dismiss()
|
||||
}
|
||||
|
||||
@IBAction func add(_ sender: Any) {
|
||||
let _ = AccountManager.shared.createAccount(type: .cloudKit)
|
||||
dismiss(animated: true, completion: nil)
|
||||
delegate?.dismiss()
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat {
|
||||
return section == 0 ? ImageHeaderView.rowHeight : super.tableView(tableView, heightForHeaderInSection: section)
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? {
|
||||
if section == 0 {
|
||||
let headerView = tableView.dequeueReusableHeaderFooterView(withIdentifier: "SectionHeader") as! ImageHeaderView
|
||||
headerView.imageView.image = AppAssets.image(for: .cloudKit)
|
||||
return headerView
|
||||
} else {
|
||||
return super.tableView(tableView, viewForHeaderInSection: section)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -29,7 +29,7 @@ class NewsBlurAccountViewController: UITableViewController {
|
|||
usernameTextField.delegate = self
|
||||
passwordTextField.delegate = self
|
||||
|
||||
if let account = account, let credentials = try? account.retrieveCredentials(type: .basic) {
|
||||
if let account = account, let credentials = try? account.retrieveCredentials(type: .newsBlurBasic) {
|
||||
actionButton.setTitle(NSLocalizedString("Update Credentials", comment: "Update Credentials"), for: .normal)
|
||||
actionButton.isEnabled = true
|
||||
usernameTextField.text = credentials.username
|
||||
|
@ -90,7 +90,7 @@ class NewsBlurAccountViewController: UITableViewController {
|
|||
let credentials = Credentials(type: .newsBlurBasic, username: trimmedUsername, secret: password)
|
||||
Account.validateCredentials(type: .newsBlur, credentials: credentials) { result in
|
||||
|
||||
self.stopAnimtatingActivityIndicator()
|
||||
self.stopAnimatingActivityIndicator()
|
||||
self.enableNavigation()
|
||||
|
||||
switch result {
|
||||
|
@ -105,7 +105,7 @@ class NewsBlurAccountViewController: UITableViewController {
|
|||
do {
|
||||
|
||||
do {
|
||||
try self.account?.removeCredentials(type: .basic)
|
||||
try self.account?.removeCredentials(type: .newsBlurBasic)
|
||||
} catch {}
|
||||
try self.account?.storeCredentials(credentials)
|
||||
|
||||
|
@ -158,7 +158,7 @@ class NewsBlurAccountViewController: UITableViewController {
|
|||
activityIndicator.startAnimating()
|
||||
}
|
||||
|
||||
private func stopAnimtatingActivityIndicator() {
|
||||
private func stopAnimatingActivityIndicator() {
|
||||
self.activityIndicator.isHidden = true
|
||||
self.activityIndicator.stopAnimating()
|
||||
}
|
||||
|
|
|
@ -19,6 +19,10 @@ struct AppAssets {
|
|||
return UIImage(named: "accountLocalPhone")!
|
||||
}()
|
||||
|
||||
static var accountCloudKitImage: UIImage = {
|
||||
return UIImage(named: "accountCloudKit")!
|
||||
}()
|
||||
|
||||
static var accountFeedbinImage: UIImage = {
|
||||
return UIImage(named: "accountFeedbin")!
|
||||
}()
|
||||
|
@ -234,6 +238,8 @@ struct AppAssets {
|
|||
} else {
|
||||
return AppAssets.accountLocalPhoneImage
|
||||
}
|
||||
case .cloudKit:
|
||||
return AppAssets.accountCloudKitImage
|
||||
case .feedbin:
|
||||
return AppAssets.accountFeedbinImage
|
||||
case .feedly:
|
||||
|
@ -244,8 +250,6 @@ struct AppAssets {
|
|||
return AppAssets.accountFreshRSSImage
|
||||
case .newsBlur:
|
||||
return AppAssets.accountNewsBlurImage
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -52,6 +52,13 @@ struct AppDefaults {
|
|||
static let addFolderAccountID = "addFolderAccountID"
|
||||
}
|
||||
|
||||
static let isDeveloperBuild: Bool = {
|
||||
if let dev = Bundle.main.object(forInfoDictionaryKey: "DeveloperEntitlements") as? String, dev == "-dev" {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}()
|
||||
|
||||
static let isFirstRun: Bool = {
|
||||
if let _ = AppDefaults.shared.object(forKey: Key.firstRunDate) as? Date {
|
||||
return false
|
||||
|
|
|
@ -91,15 +91,10 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
|
|||
self.unreadCount = AccountManager.shared.unreadCount
|
||||
}
|
||||
|
||||
UNUserNotificationCenter.current().requestAuthorization(options:[.badge, .sound, .alert]) { (granted, error) in
|
||||
if granted {
|
||||
DispatchQueue.main.async {
|
||||
UIApplication.shared.registerForRemoteNotifications()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
UNUserNotificationCenter.current().delegate = self
|
||||
UNUserNotificationCenter.current().requestAuthorization(options:[.badge, .sound, .alert]) { _, _ in }
|
||||
UIApplication.shared.registerForRemoteNotifications()
|
||||
|
||||
userNotificationManager = UserNotificationManager()
|
||||
|
||||
extensionContainersFile = ExtensionContainersFile()
|
||||
|
@ -115,6 +110,16 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
|
|||
|
||||
}
|
||||
|
||||
func application(_ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable : Any], fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) {
|
||||
DispatchQueue.main.async {
|
||||
self.resumeDatabaseProcessingIfNecessary()
|
||||
AccountManager.shared.receiveRemoteNotification(userInfo: userInfo) {
|
||||
self.suspendApplication()
|
||||
completionHandler(.newData)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func applicationWillTerminate(_ application: UIApplication) {
|
||||
shuttingDown = true
|
||||
}
|
||||
|
|
|
@ -153,7 +153,7 @@ class ArticleViewController: UIViewController {
|
|||
starBarButtonItem.isEnabled = true
|
||||
|
||||
let permalinkPresent = article.preferredLink != nil
|
||||
articleExtractorButton.isEnabled = permalinkPresent
|
||||
articleExtractorButton.isEnabled = permalinkPresent && !AppDefaults.isDeveloperBuild
|
||||
actionBarButtonItem.isEnabled = permalinkPresent
|
||||
|
||||
if article.status.read {
|
||||
|
|
|
@ -37,7 +37,7 @@ class OpenInSafariActivity: UIActivity {
|
|||
}
|
||||
|
||||
override func perform() {
|
||||
guard let url = activityItems?.firstElementPassingTest({ $0 is URL }) as? URL else {
|
||||
guard let url = activityItems?.first(where: { $0 is URL }) as? URL else {
|
||||
activityDidFinish(false)
|
||||
return
|
||||
}
|
||||
|
|
|
@ -107,8 +107,7 @@ extension AccountInspectorViewController {
|
|||
return true
|
||||
}
|
||||
switch account.type {
|
||||
case .onMyMac,
|
||||
.feedly:
|
||||
case .onMyMac, .cloudKit, .feedly:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
|
|
|
@ -12,7 +12,7 @@ import Account
|
|||
import RSTree
|
||||
|
||||
protocol MasterFeedTableViewCellDelegate: class {
|
||||
func disclosureSelected(_ sender: MasterFeedTableViewCell, expanding: Bool)
|
||||
func masterFeedTableViewCellDisclosureDidToggle(_ sender: MasterFeedTableViewCell, expanding: Bool)
|
||||
}
|
||||
|
||||
class MasterFeedTableViewCell : VibrantTableViewCell {
|
||||
|
@ -149,7 +149,7 @@ class MasterFeedTableViewCell : VibrantTableViewCell {
|
|||
@objc func buttonPressed(_ sender: UIButton) {
|
||||
if isDisclosureAvailable {
|
||||
setDisclosure(isExpanded: !isDisclosureExpanded, animated: true)
|
||||
delegate?.disclosureSelected(self, expanding: isDisclosureExpanded)
|
||||
delegate?.masterFeedTableViewCellDisclosureDidToggle(self, expanding: isDisclosureExpanded)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -181,6 +181,9 @@ private extension MasterFeedTableViewCell {
|
|||
disclosureButton?.tintColor = AppAssets.controlBackgroundColor
|
||||
disclosureButton?.imageView?.contentMode = .center
|
||||
disclosureButton?.imageView?.clipsToBounds = false
|
||||
if #available(iOS 13.4, *) {
|
||||
disclosureButton?.addInteraction(UIPointerInteraction())
|
||||
}
|
||||
addSubviewAtInit(disclosureButton!)
|
||||
}
|
||||
|
||||
|
|
|
@ -8,8 +8,14 @@
|
|||
|
||||
import UIKit
|
||||
|
||||
protocol MasterFeedTableViewSectionHeaderDelegate {
|
||||
func masterFeedTableViewSectionHeaderDisclosureDidToggle(_ sender: MasterFeedTableViewSectionHeader)
|
||||
}
|
||||
|
||||
class MasterFeedTableViewSectionHeader: UITableViewHeaderFooterView {
|
||||
|
||||
var delegate: MasterFeedTableViewSectionHeaderDelegate?
|
||||
|
||||
override var accessibilityLabel: String? {
|
||||
set {}
|
||||
get {
|
||||
|
@ -66,12 +72,16 @@ class MasterFeedTableViewSectionHeader: UITableViewHeaderFooterView {
|
|||
}()
|
||||
|
||||
private let unreadCountView = MasterFeedUnreadCountView(frame: CGRect.zero)
|
||||
private var disclosureView: UIImageView = {
|
||||
let iView = NonIntrinsicImageView()
|
||||
iView.tintColor = UIColor.tertiaryLabel
|
||||
iView.image = AppAssets.disclosureImage
|
||||
iView.contentMode = .center
|
||||
return iView
|
||||
private lazy var disclosureButton: UIButton = {
|
||||
let button = NonIntrinsicButton()
|
||||
button.tintColor = UIColor.tertiaryLabel
|
||||
button.setImage(AppAssets.disclosureImage, for: .normal)
|
||||
button.contentMode = .center
|
||||
if #available(iOS 13.4, *) {
|
||||
button.addInteraction(UIPointerInteraction())
|
||||
}
|
||||
button.addTarget(self, action: #selector(toggleDisclosure), for: .touchUpInside)
|
||||
return button
|
||||
}()
|
||||
|
||||
private let topSeparatorView: UIView = {
|
||||
|
@ -115,10 +125,14 @@ class MasterFeedTableViewSectionHeader: UITableViewHeaderFooterView {
|
|||
|
||||
private extension MasterFeedTableViewSectionHeader {
|
||||
|
||||
@objc func toggleDisclosure() {
|
||||
delegate?.masterFeedTableViewSectionHeaderDisclosureDidToggle(self)
|
||||
}
|
||||
|
||||
func commonInit() {
|
||||
addSubviewAtInit(unreadCountView)
|
||||
addSubviewAtInit(titleView)
|
||||
addSubviewAtInit(disclosureView)
|
||||
addSubviewAtInit(disclosureButton)
|
||||
updateExpandedState(animate: false)
|
||||
addBackgroundView()
|
||||
addSubviewAtInit(topSeparatorView)
|
||||
|
@ -136,9 +150,9 @@ private extension MasterFeedTableViewSectionHeader {
|
|||
withDuration: duration,
|
||||
animations: {
|
||||
if self.disclosureExpanded {
|
||||
self.disclosureView.transform = CGAffineTransform(rotationAngle: 1.570796)
|
||||
self.disclosureButton.transform = CGAffineTransform(rotationAngle: 1.570796)
|
||||
} else {
|
||||
self.disclosureView.transform = CGAffineTransform(rotationAngle: 0)
|
||||
self.disclosureButton.transform = CGAffineTransform(rotationAngle: 0)
|
||||
}
|
||||
}, completion: { _ in
|
||||
if !self.isLastSection && !self.disclosureExpanded {
|
||||
|
@ -167,7 +181,7 @@ private extension MasterFeedTableViewSectionHeader {
|
|||
func layoutWith(_ layout: MasterFeedTableViewSectionHeaderLayout) {
|
||||
titleView.setFrameIfNotEqual(layout.titleRect)
|
||||
unreadCountView.setFrameIfNotEqual(layout.unreadCountRect)
|
||||
disclosureView.setFrameIfNotEqual(layout.disclosureButtonRect)
|
||||
disclosureButton.setFrameIfNotEqual(layout.disclosureButtonRect)
|
||||
|
||||
let top = CGRect(x: safeAreaInsets.left, y: 0, width: frame.width - safeAreaInsets.right - safeAreaInsets.left, height: 0.33)
|
||||
topSeparatorView.setFrameIfNotEqual(top)
|
||||
|
|
|
@ -158,6 +158,7 @@ class MasterFeedViewController: UITableViewController, UndoableCommandRunner {
|
|||
}
|
||||
|
||||
let headerView = tableView.dequeueReusableHeaderFooterView(withIdentifier: "SectionHeader") as! MasterFeedTableViewSectionHeader
|
||||
headerView.delegate = self
|
||||
headerView.name = nameProvider.nameForDisplay
|
||||
|
||||
guard let sectionNode = coordinator.rootNode.childAtIndex(section) else {
|
||||
|
@ -386,24 +387,10 @@ class MasterFeedViewController: UITableViewController, UndoableCommandRunner {
|
|||
}
|
||||
|
||||
@objc func toggleSectionHeader(_ sender: UITapGestureRecognizer) {
|
||||
|
||||
guard let sectionIndex = sender.view?.tag,
|
||||
let sectionNode = coordinator.rootNode.childAtIndex(sectionIndex),
|
||||
let headerView = sender.view as? MasterFeedTableViewSectionHeader
|
||||
else {
|
||||
guard let headerView = sender.view as? MasterFeedTableViewSectionHeader else {
|
||||
return
|
||||
}
|
||||
|
||||
if coordinator.isExpanded(sectionNode) {
|
||||
headerView.disclosureExpanded = false
|
||||
coordinator.collapse(sectionNode)
|
||||
self.applyChanges(animated: true)
|
||||
} else {
|
||||
headerView.disclosureExpanded = true
|
||||
coordinator.expand(sectionNode)
|
||||
self.applyChanges(animated: true)
|
||||
}
|
||||
|
||||
toggle(headerView)
|
||||
}
|
||||
|
||||
@objc func refreshAccounts(_ sender: Any) {
|
||||
|
@ -556,11 +543,21 @@ extension MasterFeedViewController: UIContextMenuInteractionDelegate {
|
|||
}
|
||||
}
|
||||
|
||||
// MARK: MasterFeedTableViewSectionHeaderDelegate
|
||||
|
||||
extension MasterFeedViewController: MasterFeedTableViewSectionHeaderDelegate {
|
||||
|
||||
func masterFeedTableViewSectionHeaderDisclosureDidToggle(_ sender: MasterFeedTableViewSectionHeader) {
|
||||
toggle(sender)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: MasterTableViewCellDelegate
|
||||
|
||||
extension MasterFeedViewController: MasterFeedTableViewCellDelegate {
|
||||
|
||||
func disclosureSelected(_ sender: MasterFeedTableViewCell, expanding: Bool) {
|
||||
func masterFeedTableViewCellDisclosureDidToggle(_ sender: MasterFeedTableViewCell, expanding: Bool) {
|
||||
if expanding {
|
||||
expand(sender)
|
||||
} else {
|
||||
|
@ -753,6 +750,22 @@ private extension MasterFeedViewController {
|
|||
return nil
|
||||
}
|
||||
|
||||
func toggle(_ headerView: MasterFeedTableViewSectionHeader) {
|
||||
guard let sectionNode = coordinator.rootNode.childAtIndex(headerView.tag) else {
|
||||
return
|
||||
}
|
||||
|
||||
if coordinator.isExpanded(sectionNode) {
|
||||
headerView.disclosureExpanded = false
|
||||
coordinator.collapse(sectionNode)
|
||||
self.applyChanges(animated: true)
|
||||
} else {
|
||||
headerView.disclosureExpanded = true
|
||||
coordinator.expand(sectionNode)
|
||||
self.applyChanges(animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
func expand(_ cell: MasterFeedTableViewCell) {
|
||||
guard let indexPath = tableView.indexPath(for: cell), let node = dataSource.itemIdentifier(for: indexPath) else {
|
||||
return
|
||||
|
|
|
@ -14,4 +14,37 @@ class MasterTimelineTitleView: UIView {
|
|||
@IBOutlet weak var label: UILabel!
|
||||
@IBOutlet weak var unreadCountView: MasterTimelineUnreadCountView!
|
||||
|
||||
@available(iOS 13.4, *)
|
||||
private lazy var pointerInteraction: UIPointerInteraction = {
|
||||
UIPointerInteraction(delegate: self)
|
||||
}()
|
||||
|
||||
func buttonize() {
|
||||
heightAnchor.constraint(equalToConstant: 40.0).isActive = true
|
||||
accessibilityTraits = .button
|
||||
if #available(iOS 13.4, *) {
|
||||
addInteraction(pointerInteraction)
|
||||
}
|
||||
}
|
||||
|
||||
func debuttonize() {
|
||||
accessibilityTraits.remove(.button)
|
||||
if #available(iOS 13.4, *) {
|
||||
removeInteraction(pointerInteraction)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension MasterTimelineTitleView: UIPointerInteractionDelegate {
|
||||
|
||||
@available(iOS 13.4, *)
|
||||
func pointerInteraction(_ interaction: UIPointerInteraction, styleFor region: UIPointerRegion) -> UIPointerStyle? {
|
||||
var rect = self.frame
|
||||
rect.origin.x = rect.origin.x - 10
|
||||
rect.size.width = rect.width + 20
|
||||
|
||||
return UIPointerStyle(effect: .automatic(UITargetedPreview(view: self)), shape: .roundedRect(rect))
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -564,12 +564,11 @@ private extension MasterTimelineViewController {
|
|||
updateTitleUnreadCount()
|
||||
|
||||
if coordinator.timelineFeed is WebFeed {
|
||||
titleView.heightAnchor.constraint(equalToConstant: 44.0).isActive = true
|
||||
titleView.buttonize()
|
||||
titleView.addGestureRecognizer(feedTapGestureRecognizer)
|
||||
titleView.accessibilityTraits = .button
|
||||
} else {
|
||||
titleView.debuttonize()
|
||||
titleView.removeGestureRecognizer(feedTapGestureRecognizer)
|
||||
titleView.accessibilityTraits.remove(.button)
|
||||
}
|
||||
|
||||
navigationItem.titleView = titleView
|
||||
|
|
|
@ -0,0 +1,15 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "icloud.pdf",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
},
|
||||
"properties" : {
|
||||
"template-rendering-intent" : "template"
|
||||
}
|
||||
}
|
Binary file not shown.
|
@ -92,6 +92,8 @@
|
|||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>fetch</string>
|
||||
<string>processing</string>
|
||||
<string>remote-notification</string>
|
||||
</array>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
<string>LaunchScreenPhone</string>
|
||||
|
|
|
@ -61,7 +61,7 @@ class SceneCoordinator: NSObject, UndoableCommandRunner, UnreadCountProvider {
|
|||
private var wasRootSplitViewControllerCollapsed = false
|
||||
|
||||
private let fetchAndMergeArticlesQueue = CoalescingQueue(name: "Fetch and Merge Articles", interval: 0.5)
|
||||
private let rebuildBackingStoresWithMergeQueue = CoalescingQueue(name: "Rebuild The Backing Stores by Merging", interval: 1.0)
|
||||
private let rebuildBackingStoresQueue = CoalescingQueue(name: "Rebuild The Backing Stores", interval: 1.0)
|
||||
private var fetchSerialNumber = 0
|
||||
private let fetchRequestQueue = FetchRequestQueue()
|
||||
|
||||
|
@ -301,7 +301,6 @@ class SceneCoordinator: NSObject, UndoableCommandRunner, UnreadCountProvider {
|
|||
NotificationCenter.default.addObserver(self, selector: #selector(userDidDeleteAccount(_:)), name: .UserDidDeleteAccount, object: nil)
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(userDidAddFeed(_:)), name: .UserDidAddFeed, object: nil)
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(userDefaultsDidChange(_:)), name: UserDefaults.didChangeNotification, object: nil)
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(downloadArticlesDidUpdateUnreadCounts(_:)), name: .DownloadArticlesDidUpdateUnreadCounts, object: nil)
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(accountDidDownloadArticles(_:)), name: .AccountDidDownloadArticles, object: nil)
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(willEnterForeground(_:)), name: UIApplication.willEnterForegroundNotification, object: nil)
|
||||
|
||||
|
@ -441,12 +440,7 @@ class SceneCoordinator: NSObject, UndoableCommandRunner, UnreadCountProvider {
|
|||
return
|
||||
}
|
||||
|
||||
// If we are filtering reads, the new unread count is greater than 1, and the feed isn't shown then continue
|
||||
guard let feed = note.object as? Feed, isReadFeedsFiltered, feed.unreadCount > 0, !shadowTableContains(feed) else {
|
||||
return
|
||||
}
|
||||
|
||||
rebuildBackingStoresWithMergeQueue.add(self, #selector(rebuildBackingStoresWithMerge))
|
||||
queueRebuildBackingStores()
|
||||
}
|
||||
|
||||
@objc func statusesDidChange(_ note: Notification) {
|
||||
|
@ -465,7 +459,7 @@ class SceneCoordinator: NSObject, UndoableCommandRunner, UnreadCountProvider {
|
|||
}
|
||||
|
||||
@objc func batchUpdateDidPerform(_ notification: Notification) {
|
||||
rebuildBackingStoresWithMerge()
|
||||
rebuildBackingStores()
|
||||
}
|
||||
|
||||
@objc func displayNameDidChange(_ note: Notification) {
|
||||
|
@ -540,10 +534,6 @@ class SceneCoordinator: NSObject, UndoableCommandRunner, UnreadCountProvider {
|
|||
self.groupByFeed = AppDefaults.timelineGroupByFeed
|
||||
}
|
||||
|
||||
@objc func downloadArticlesDidUpdateUnreadCounts(_ note: Notification) {
|
||||
rebuildBackingStoresWithMerge()
|
||||
}
|
||||
|
||||
@objc func accountDidDownloadArticles(_ note: Notification) {
|
||||
guard let feeds = note.userInfo?[Account.UserInfoKey.webFeeds] as? Set<WebFeed> else {
|
||||
return
|
||||
|
@ -568,7 +558,7 @@ class SceneCoordinator: NSObject, UndoableCommandRunner, UnreadCountProvider {
|
|||
|
||||
func suspend() {
|
||||
fetchAndMergeArticlesQueue.performCallsImmediately()
|
||||
rebuildBackingStoresWithMergeQueue.performCallsImmediately()
|
||||
rebuildBackingStoresQueue.performCallsImmediately()
|
||||
fetchRequestQueue.cancelAllRequests()
|
||||
}
|
||||
|
||||
|
@ -745,6 +735,9 @@ class SceneCoordinator: NSObject, UndoableCommandRunner, UnreadCountProvider {
|
|||
} else {
|
||||
|
||||
setTimelineFeed(nil, animated: false) {
|
||||
if self.isReadFeedsFiltered {
|
||||
self.queueRebuildBackingStores()
|
||||
}
|
||||
self.activityManager.invalidateSelecting()
|
||||
if self.rootSplitViewController.isCollapsed && self.navControllerForTimeline().viewControllers.last is MasterTimelineViewController {
|
||||
self.navControllerForTimeline().popViewController(animated: animations.contains(.navigation))
|
||||
|
@ -929,7 +922,6 @@ class SceneCoordinator: NSObject, UndoableCommandRunner, UnreadCountProvider {
|
|||
}
|
||||
|
||||
if selectNextUnreadArticleInTimeline() {
|
||||
activityManager.selectingNextUnread()
|
||||
return
|
||||
}
|
||||
|
||||
|
@ -938,9 +930,7 @@ class SceneCoordinator: NSObject, UndoableCommandRunner, UnreadCountProvider {
|
|||
}
|
||||
|
||||
selectNextUnreadFeed() {
|
||||
if self.selectNextUnreadArticleInTimeline() {
|
||||
self.activityManager.selectingNextUnread()
|
||||
}
|
||||
self.selectNextUnreadArticleInTimeline()
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -1361,6 +1351,14 @@ private extension SceneCoordinator {
|
|||
}
|
||||
}
|
||||
|
||||
func queueRebuildBackingStores() {
|
||||
rebuildBackingStoresQueue.add(self, #selector(rebuildBackingStoresWithDefaults))
|
||||
}
|
||||
|
||||
@objc func rebuildBackingStoresWithDefaults() {
|
||||
rebuildBackingStores()
|
||||
}
|
||||
|
||||
func rebuildBackingStores(initialLoad: Bool = false, updateExpandedNodes: (() -> Void)? = nil, completion: (() -> Void)? = nil) {
|
||||
if !animatingChanges && !BatchUpdate.shared.isPerforming {
|
||||
|
||||
|
@ -1375,11 +1373,6 @@ private extension SceneCoordinator {
|
|||
}
|
||||
}
|
||||
|
||||
@objc func rebuildBackingStoresWithMerge() {
|
||||
addShadowTableToFilterExceptions()
|
||||
rebuildBackingStores()
|
||||
}
|
||||
|
||||
func rebuildShadowTable() {
|
||||
shadowTable = [[Node]]()
|
||||
|
||||
|
|
|
@ -16,47 +16,90 @@ protocol AddAccountDismissDelegate: UIViewController {
|
|||
|
||||
class AddAccountViewController: UITableViewController, AddAccountDismissDelegate {
|
||||
|
||||
@IBOutlet private weak var localAccountImageView: UIImageView!
|
||||
@IBOutlet private weak var localAccountNameLabel: UILabel!
|
||||
#if DEBUG
|
||||
private var addableAccountTypes: [AccountType] = [.onMyMac, .feedbin, .feedly, .feedWrangler, .cloudKit, .newsBlur]
|
||||
#else
|
||||
private var addableAccountTypes: [AccountType] = [.onMyMac, .feedbin, .feedly]
|
||||
#endif
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
localAccountImageView.image = AppAssets.image(for: .onMyMac)
|
||||
localAccountNameLabel.text = Account.defaultLocalAccountName
|
||||
restrictAccounts()
|
||||
}
|
||||
|
||||
#if !DEBUG
|
||||
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
return 3
|
||||
override func numberOfSections(in tableView: UITableView) -> Int {
|
||||
1
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
return addableAccountTypes.count
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
|
||||
return 52.0
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: "SettingsAccountTableViewCell", for: indexPath) as! SettingsAccountTableViewCell
|
||||
|
||||
switch addableAccountTypes[indexPath.row] {
|
||||
case .onMyMac:
|
||||
cell.accountNameLabel?.text = Account.defaultLocalAccountName
|
||||
cell.accountImage?.image = AppAssets.image(for: .onMyMac)
|
||||
case .cloudKit:
|
||||
cell.accountNameLabel?.text = NSLocalizedString("iCloud", comment: "iCloud")
|
||||
cell.accountImage?.image = AppAssets.accountCloudKitImage
|
||||
case .feedbin:
|
||||
cell.accountNameLabel?.text = NSLocalizedString("Feedbin", comment: "Feedbin")
|
||||
cell.accountImage?.image = AppAssets.accountFeedbinImage
|
||||
case .feedWrangler:
|
||||
cell.accountNameLabel?.text = NSLocalizedString("Feed Wrangler", comment: "Feed Wrangler")
|
||||
cell.accountImage?.image = AppAssets.accountFeedWranglerImage
|
||||
case .feedly:
|
||||
cell.accountNameLabel?.text = NSLocalizedString("Feedly", comment: "Feedly")
|
||||
cell.accountImage?.image = AppAssets.accountFeedlyImage
|
||||
case .newsBlur:
|
||||
cell.accountNameLabel?.text = NSLocalizedString("NewsBlur", comment: "NewsBlur")
|
||||
cell.accountImage?.image = AppAssets.accountNewsBlurImage
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
return cell
|
||||
}
|
||||
#endif
|
||||
|
||||
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
switch indexPath.row {
|
||||
case 0:
|
||||
let navController = UIStoryboard.account.instantiateViewController(withIdentifier: "AddLocalAccountNavigationViewController") as! UINavigationController
|
||||
switch addableAccountTypes[indexPath.row] {
|
||||
case .onMyMac:
|
||||
let navController = UIStoryboard.account.instantiateViewController(withIdentifier: "LocalAccountNavigationViewController") as! UINavigationController
|
||||
navController.modalPresentationStyle = .currentContext
|
||||
let addViewController = navController.topViewController as! LocalAccountViewController
|
||||
addViewController.delegate = self
|
||||
present(navController, animated: true)
|
||||
case 1:
|
||||
case .cloudKit:
|
||||
let navController = UIStoryboard.account.instantiateViewController(withIdentifier: "CloudKitAccountNavigationViewController") as! UINavigationController
|
||||
navController.modalPresentationStyle = .currentContext
|
||||
let addViewController = navController.topViewController as! CloudKitAccountViewController
|
||||
addViewController.delegate = self
|
||||
present(navController, animated: true)
|
||||
case .feedbin:
|
||||
let navController = UIStoryboard.account.instantiateViewController(withIdentifier: "FeedbinAccountNavigationViewController") as! UINavigationController
|
||||
navController.modalPresentationStyle = .currentContext
|
||||
let addViewController = navController.topViewController as! FeedbinAccountViewController
|
||||
addViewController.delegate = self
|
||||
present(navController, animated: true)
|
||||
case 2:
|
||||
case .feedly:
|
||||
let addAccount = OAuthAccountAuthorizationOperation(accountType: .feedly)
|
||||
addAccount.delegate = self
|
||||
addAccount.presentationAnchor = self.view.window!
|
||||
MainThreadOperationQueue.shared.add(addAccount)
|
||||
case 3:
|
||||
case .feedWrangler:
|
||||
let navController = UIStoryboard.account.instantiateViewController(withIdentifier: "FeedWranglerAccountNavigationViewController") as! UINavigationController
|
||||
navController.modalPresentationStyle = .currentContext
|
||||
let addViewController = navController.topViewController as! FeedWranglerAccountViewController
|
||||
addViewController.delegate = self
|
||||
present(navController, animated: true)
|
||||
case 4:
|
||||
case .newsBlur:
|
||||
let navController = UIStoryboard.account.instantiateViewController(withIdentifier: "NewsBlurAccountNavigationViewController") as! UINavigationController
|
||||
navController.modalPresentationStyle = .currentContext
|
||||
let addViewController = navController.topViewController as! NewsBlurAccountViewController
|
||||
|
@ -97,3 +140,28 @@ extension AddAccountViewController: OAuthAccountAuthorizationOperationDelegate {
|
|||
presentError(error)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Private
|
||||
|
||||
private extension AddAccountViewController {
|
||||
|
||||
func restrictAccounts() {
|
||||
func removeAccountType(_ accountType: AccountType) {
|
||||
if let index = addableAccountTypes.firstIndex(of: accountType) {
|
||||
addableAccountTypes.remove(at: index)
|
||||
}
|
||||
}
|
||||
|
||||
if AppDefaults.isDeveloperBuild {
|
||||
removeAccountType(.cloudKit)
|
||||
removeAccountType(.feedly)
|
||||
removeAccountType(.feedWrangler)
|
||||
return
|
||||
}
|
||||
|
||||
if AccountManager.shared.activeAccounts.firstIndex(where: { $0.type == .cloudKit }) != nil {
|
||||
removeAccountType(.cloudKit)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="15505" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="9cW-lu-HoC">
|
||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="16096" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="9cW-lu-HoC">
|
||||
<device id="retina6_1" orientation="portrait" appearance="light"/>
|
||||
<dependencies>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="15509"/>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="16086"/>
|
||||
<capability name="Named colors" minToolsVersion="9.0"/>
|
||||
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
|
||||
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
|
||||
|
@ -320,7 +320,7 @@
|
|||
<tableViewSection headerTitle="Appearance" id="TkH-4v-yhk">
|
||||
<cells>
|
||||
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="default" accessoryType="disclosureIndicator" indentationWidth="10" id="EvG-yE-gDF" customClass="VibrantBasicTableViewCell" customModule="NetNewsWire" customModuleProvider="target">
|
||||
<rect key="frame" x="0.0" y="819.5" width="374" height="44"/>
|
||||
<rect key="frame" x="20" y="819.5" width="374" height="44"/>
|
||||
<autoresizingMask key="autoresizingMask"/>
|
||||
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="EvG-yE-gDF" id="wBN-zJ-6pN">
|
||||
<rect key="frame" x="0.0" y="0.0" width="343" height="44"/>
|
||||
|
@ -356,7 +356,7 @@
|
|||
<tableViewSection headerTitle="Help" id="CS8-fJ-ghn">
|
||||
<cells>
|
||||
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="default" indentationWidth="10" textLabel="uGk-2d-oFc" style="IBUITableViewCellStyleDefault" id="Tle-IV-D40" customClass="VibrantTableViewCell" customModule="NetNewsWire" customModuleProvider="target">
|
||||
<rect key="frame" x="0.0" y="919.5" width="374" height="44"/>
|
||||
<rect key="frame" x="20" y="919.5" width="374" height="44"/>
|
||||
<autoresizingMask key="autoresizingMask"/>
|
||||
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="Tle-IV-D40" id="IJD-ZB-8Wm">
|
||||
<rect key="frame" x="0.0" y="0.0" width="374" height="44"/>
|
||||
|
@ -373,7 +373,7 @@
|
|||
</tableViewCellContentView>
|
||||
</tableViewCell>
|
||||
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="default" indentationWidth="10" textLabel="6G3-yV-Eyh" style="IBUITableViewCellStyleDefault" id="Tbf-fE-nfx" customClass="VibrantTableViewCell" customModule="NetNewsWire" customModuleProvider="target">
|
||||
<rect key="frame" x="0.0" y="963.5" width="374" height="44"/>
|
||||
<rect key="frame" x="20" y="963.5" width="374" height="44"/>
|
||||
<autoresizingMask key="autoresizingMask"/>
|
||||
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="Tbf-fE-nfx" id="beV-vI-g3r">
|
||||
<rect key="frame" x="0.0" y="0.0" width="374" height="44"/>
|
||||
|
@ -390,7 +390,7 @@
|
|||
</tableViewCellContentView>
|
||||
</tableViewCell>
|
||||
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="default" indentationWidth="10" textLabel="lfL-bQ-sOp" style="IBUITableViewCellStyleDefault" id="mFn-fE-zqa" customClass="VibrantTableViewCell" customModule="NetNewsWire" customModuleProvider="target">
|
||||
<rect key="frame" x="0.0" y="1007.5" width="374" height="44"/>
|
||||
<rect key="frame" x="20" y="1007.5" width="374" height="44"/>
|
||||
<autoresizingMask key="autoresizingMask"/>
|
||||
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="mFn-fE-zqa" id="jTe-mf-MRj">
|
||||
<rect key="frame" x="0.0" y="0.0" width="374" height="44"/>
|
||||
|
@ -407,7 +407,7 @@
|
|||
</tableViewCellContentView>
|
||||
</tableViewCell>
|
||||
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="default" indentationWidth="10" textLabel="DDJ-8P-3YY" style="IBUITableViewCellStyleDefault" id="iGs-ze-4gQ" customClass="VibrantTableViewCell" customModule="NetNewsWire" customModuleProvider="target">
|
||||
<rect key="frame" x="0.0" y="1051.5" width="374" height="44"/>
|
||||
<rect key="frame" x="20" y="1051.5" width="374" height="44"/>
|
||||
<autoresizingMask key="autoresizingMask"/>
|
||||
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="iGs-ze-4gQ" id="EqZ-rF-N0l">
|
||||
<rect key="frame" x="0.0" y="0.0" width="374" height="44"/>
|
||||
|
@ -424,7 +424,7 @@
|
|||
</tableViewCellContentView>
|
||||
</tableViewCell>
|
||||
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="default" indentationWidth="10" textLabel="DsV-Qv-X4K" style="IBUITableViewCellStyleDefault" id="taJ-sg-wnU" customClass="VibrantTableViewCell" customModule="NetNewsWire" customModuleProvider="target">
|
||||
<rect key="frame" x="0.0" y="1095.5" width="374" height="44"/>
|
||||
<rect key="frame" x="20" y="1095.5" width="374" height="44"/>
|
||||
<autoresizingMask key="autoresizingMask"/>
|
||||
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="taJ-sg-wnU" id="axB-si-1KM">
|
||||
<rect key="frame" x="0.0" y="0.0" width="374" height="44"/>
|
||||
|
@ -441,7 +441,7 @@
|
|||
</tableViewCellContentView>
|
||||
</tableViewCell>
|
||||
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="default" indentationWidth="10" textLabel="zMz-hU-UYU" style="IBUITableViewCellStyleDefault" id="OXi-cg-ab9" customClass="VibrantTableViewCell" customModule="NetNewsWire" customModuleProvider="target">
|
||||
<rect key="frame" x="0.0" y="1139.5" width="374" height="44"/>
|
||||
<rect key="frame" x="20" y="1139.5" width="374" height="44"/>
|
||||
<autoresizingMask key="autoresizingMask"/>
|
||||
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="OXi-cg-ab9" id="npR-a0-9wv">
|
||||
<rect key="frame" x="0.0" y="0.0" width="374" height="44"/>
|
||||
|
@ -458,7 +458,7 @@
|
|||
</tableViewCellContentView>
|
||||
</tableViewCell>
|
||||
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="default" indentationWidth="10" textLabel="T7x-zl-6Yf" style="IBUITableViewCellStyleDefault" id="VpI-0o-3Px" customClass="VibrantTableViewCell" customModule="NetNewsWire" customModuleProvider="target">
|
||||
<rect key="frame" x="0.0" y="1183.5" width="374" height="44"/>
|
||||
<rect key="frame" x="20" y="1183.5" width="374" height="44"/>
|
||||
<autoresizingMask key="autoresizingMask"/>
|
||||
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="VpI-0o-3Px" id="xRH-i4-vne">
|
||||
<rect key="frame" x="0.0" y="0.0" width="374" height="44"/>
|
||||
|
@ -475,7 +475,7 @@
|
|||
</tableViewCellContentView>
|
||||
</tableViewCell>
|
||||
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="default" accessoryType="disclosureIndicator" indentationWidth="10" textLabel="NeD-y8-KrM" style="IBUITableViewCellStyleDefault" id="TIX-yK-rC6" customClass="VibrantTableViewCell" customModule="NetNewsWire" customModuleProvider="target">
|
||||
<rect key="frame" x="0.0" y="1227.5" width="374" height="44"/>
|
||||
<rect key="frame" x="20" y="1227.5" width="374" height="44"/>
|
||||
<autoresizingMask key="autoresizingMask"/>
|
||||
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="TIX-yK-rC6" id="qr8-EN-Ofg">
|
||||
<rect key="frame" x="0.0" y="0.0" width="355" height="44"/>
|
||||
|
@ -523,14 +523,12 @@
|
|||
<scene sceneID="HbE-f2-Dbd">
|
||||
<objects>
|
||||
<tableViewController storyboardIdentifier="AddAccountViewController" id="b00-4A-bV6" customClass="AddAccountViewController" customModule="NetNewsWire" customModuleProvider="target" sceneMemberID="viewController">
|
||||
<tableView key="view" clipsSubviews="YES" contentMode="scaleToFill" alwaysBounceVertical="YES" dataMode="static" style="insetGrouped" separatorStyle="default" rowHeight="-1" estimatedRowHeight="-1" sectionHeaderHeight="18" sectionFooterHeight="18" id="nw8-FO-Me5">
|
||||
<tableView key="view" clipsSubviews="YES" contentMode="scaleToFill" alwaysBounceVertical="YES" dataMode="prototypes" style="insetGrouped" separatorStyle="default" rowHeight="-1" estimatedRowHeight="-1" sectionHeaderHeight="18" sectionFooterHeight="18" id="nw8-FO-Me5">
|
||||
<rect key="frame" x="0.0" y="0.0" width="414" height="896"/>
|
||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||
<sections>
|
||||
<tableViewSection id="m3P-em-PgI">
|
||||
<cells>
|
||||
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="default" indentationWidth="10" rowHeight="55" id="UFl-6I-ucw" customClass="SettingsAccountTableViewCell" customModule="NetNewsWire" customModuleProvider="target">
|
||||
<rect key="frame" x="20" y="18" width="374" height="55"/>
|
||||
<prototypes>
|
||||
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="default" indentationWidth="10" reuseIdentifier="SettingsAccountTableViewCell" rowHeight="55" id="UFl-6I-ucw" customClass="SettingsAccountTableViewCell" customModule="NetNewsWire" customModuleProvider="target">
|
||||
<rect key="frame" x="20" y="55.5" width="374" height="55"/>
|
||||
<autoresizingMask key="autoresizingMask"/>
|
||||
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="UFl-6I-ucw" id="99i-Ge-guB">
|
||||
<rect key="frame" x="0.0" y="0.0" width="374" height="55"/>
|
||||
|
@ -566,166 +564,13 @@
|
|||
<outlet property="accountNameLabel" destination="116-rt-msI" id="nn5-2i-HqG"/>
|
||||
</connections>
|
||||
</tableViewCell>
|
||||
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="default" indentationWidth="10" rowHeight="56" id="te1-L9-osf" customClass="SettingsAccountTableViewCell" customModule="NetNewsWire" customModuleProvider="target">
|
||||
<rect key="frame" x="20" y="73" width="374" height="56"/>
|
||||
<autoresizingMask key="autoresizingMask"/>
|
||||
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="te1-L9-osf" id="DgY-u7-DRO">
|
||||
<rect key="frame" x="0.0" y="0.0" width="374" height="56"/>
|
||||
<autoresizingMask key="autoresizingMask"/>
|
||||
<subviews>
|
||||
<stackView opaque="NO" contentMode="scaleToFill" spacing="16" translatesAutoresizingMaskIntoConstraints="NO" id="7dy-NH-2zV">
|
||||
<rect key="frame" x="20" y="12" width="145" height="32"/>
|
||||
<subviews>
|
||||
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="accountFeedbin" translatesAutoresizingMaskIntoConstraints="NO" id="wyu-mZ-3zz">
|
||||
<rect key="frame" x="0.0" y="0.0" width="32" height="32"/>
|
||||
<color key="tintColor" systemColor="labelColor" cocoaTouchSystemColor="darkTextColor"/>
|
||||
<constraints>
|
||||
<constraint firstAttribute="height" constant="32" id="LEr-1Z-N99"/>
|
||||
<constraint firstAttribute="width" constant="32" id="fZN-HH-heN"/>
|
||||
</constraints>
|
||||
</imageView>
|
||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Feedbin" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontForContentSizeCategory="YES" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="uiN-cA-Nc5">
|
||||
<rect key="frame" x="48" y="0.0" width="97" height="32"/>
|
||||
<fontDescription key="fontDescription" style="UICTFontTextStyleTitle1"/>
|
||||
<nil key="textColor"/>
|
||||
<nil key="highlightedColor"/>
|
||||
</label>
|
||||
</subviews>
|
||||
</stackView>
|
||||
</subviews>
|
||||
<constraints>
|
||||
<constraint firstItem="7dy-NH-2zV" firstAttribute="centerY" secondItem="DgY-u7-DRO" secondAttribute="centerY" id="Flf-2s-zWu"/>
|
||||
<constraint firstItem="7dy-NH-2zV" firstAttribute="leading" secondItem="DgY-u7-DRO" secondAttribute="leading" constant="20" symbolic="YES" id="H71-Jv-7uw"/>
|
||||
</constraints>
|
||||
</tableViewCellContentView>
|
||||
<connections>
|
||||
<outlet property="accountImage" destination="wyu-mZ-3zz" id="III-7X-cz1"/>
|
||||
<outlet property="accountNameLabel" destination="uiN-cA-Nc5" id="tvs-Fo-cvB"/>
|
||||
</connections>
|
||||
</tableViewCell>
|
||||
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="default" indentationWidth="10" rowHeight="56" id="zcM-qz-glk" customClass="SettingsAccountTableViewCell" customModule="NetNewsWire" customModuleProvider="target">
|
||||
<rect key="frame" x="20" y="129" width="374" height="56"/>
|
||||
<autoresizingMask key="autoresizingMask"/>
|
||||
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="zcM-qz-glk" id="3VG-Ax-7gi">
|
||||
<rect key="frame" x="0.0" y="0.0" width="374" height="56"/>
|
||||
<autoresizingMask key="autoresizingMask"/>
|
||||
<subviews>
|
||||
<stackView opaque="NO" contentMode="scaleToFill" spacing="16" translatesAutoresizingMaskIntoConstraints="NO" id="cXZ-17-bhe">
|
||||
<rect key="frame" x="20" y="12" width="128" height="32"/>
|
||||
<subviews>
|
||||
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="accountFeedly" translatesAutoresizingMaskIntoConstraints="NO" id="fAO-P0-gtD">
|
||||
<rect key="frame" x="0.0" y="0.0" width="32" height="32"/>
|
||||
<color key="tintColor" systemColor="labelColor" cocoaTouchSystemColor="darkTextColor"/>
|
||||
<constraints>
|
||||
<constraint firstAttribute="width" constant="32" id="581-u2-SxX"/>
|
||||
<constraint firstAttribute="height" constant="32" id="onv-oj-10a"/>
|
||||
</constraints>
|
||||
</imageView>
|
||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Feedly" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontForContentSizeCategory="YES" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="u2M-c5-ujy">
|
||||
<rect key="frame" x="48" y="0.0" width="80" height="32"/>
|
||||
<fontDescription key="fontDescription" style="UICTFontTextStyleTitle1"/>
|
||||
<nil key="textColor"/>
|
||||
<nil key="highlightedColor"/>
|
||||
</label>
|
||||
</subviews>
|
||||
</stackView>
|
||||
</subviews>
|
||||
<constraints>
|
||||
<constraint firstItem="cXZ-17-bhe" firstAttribute="leading" secondItem="3VG-Ax-7gi" secondAttribute="leading" constant="20" symbolic="YES" id="BYO-oH-a6T"/>
|
||||
<constraint firstItem="cXZ-17-bhe" firstAttribute="centerY" secondItem="3VG-Ax-7gi" secondAttribute="centerY" id="r36-pZ-Siw"/>
|
||||
</constraints>
|
||||
</tableViewCellContentView>
|
||||
<connections>
|
||||
<outlet property="accountImage" destination="fAO-P0-gtD" id="z7J-sQ-zMJ"/>
|
||||
<outlet property="accountNameLabel" destination="u2M-c5-ujy" id="TFJ-Yt-NAB"/>
|
||||
</connections>
|
||||
</tableViewCell>
|
||||
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="default" indentationWidth="10" rowHeight="56" id="sKj-1P-BwI" customClass="SettingsAccountTableViewCell" customModule="NetNewsWire" customModuleProvider="target">
|
||||
<rect key="frame" x="20" y="185" width="374" height="56"/>
|
||||
<autoresizingMask key="autoresizingMask"/>
|
||||
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="sKj-1P-BwI" id="PdS-21-hdl">
|
||||
<rect key="frame" x="0.0" y="0.0" width="374" height="56"/>
|
||||
<autoresizingMask key="autoresizingMask"/>
|
||||
<subviews>
|
||||
<stackView opaque="NO" contentMode="scaleToFill" spacing="16" translatesAutoresizingMaskIntoConstraints="NO" id="TmQ-Hs-znP">
|
||||
<rect key="frame" x="20" y="12" width="223.5" height="32"/>
|
||||
<subviews>
|
||||
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="accountFeedWrangler" translatesAutoresizingMaskIntoConstraints="NO" id="pIU-f0-h1H">
|
||||
<rect key="frame" x="0.0" y="0.0" width="32" height="32"/>
|
||||
<color key="tintColor" systemColor="labelColor" cocoaTouchSystemColor="darkTextColor"/>
|
||||
<constraints>
|
||||
<constraint firstAttribute="height" constant="32" id="zA7-8h-8WG"/>
|
||||
<constraint firstAttribute="width" constant="32" id="ze0-RG-buU"/>
|
||||
</constraints>
|
||||
</imageView>
|
||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Feed Wrangler" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontForContentSizeCategory="YES" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="Dur-Qf-YYi">
|
||||
<rect key="frame" x="48" y="0.0" width="175.5" height="32"/>
|
||||
<fontDescription key="fontDescription" style="UICTFontTextStyleTitle1"/>
|
||||
<nil key="textColor"/>
|
||||
<nil key="highlightedColor"/>
|
||||
</label>
|
||||
</subviews>
|
||||
</stackView>
|
||||
</subviews>
|
||||
<constraints>
|
||||
<constraint firstItem="TmQ-Hs-znP" firstAttribute="leading" secondItem="PdS-21-hdl" secondAttribute="leading" constant="20" symbolic="YES" id="IFO-xv-Y0K"/>
|
||||
<constraint firstItem="TmQ-Hs-znP" firstAttribute="centerY" secondItem="PdS-21-hdl" secondAttribute="centerY" id="oQy-rL-HV3"/>
|
||||
</constraints>
|
||||
</tableViewCellContentView>
|
||||
<connections>
|
||||
<outlet property="accountImage" destination="pIU-f0-h1H" id="4Mm-Ym-81C"/>
|
||||
<outlet property="accountNameLabel" destination="Dur-Qf-YYi" id="DAF-c9-MJM"/>
|
||||
</connections>
|
||||
</tableViewCell>
|
||||
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="default" indentationWidth="10" rowHeight="56" id="Btn-uu-2ks" customClass="SettingsAccountTableViewCell" customModule="NetNewsWire" customModuleProvider="target">
|
||||
<rect key="frame" x="20" y="241" width="374" height="56"/>
|
||||
<autoresizingMask key="autoresizingMask"/>
|
||||
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="Btn-uu-2ks" id="rSE-Cm-Oom">
|
||||
<rect key="frame" x="0.0" y="0.0" width="374" height="56"/>
|
||||
<autoresizingMask key="autoresizingMask"/>
|
||||
<subviews>
|
||||
<stackView opaque="NO" contentMode="scaleToFill" spacing="16" translatesAutoresizingMaskIntoConstraints="NO" id="PJ5-Pm-b2p">
|
||||
<rect key="frame" x="20" y="12" width="164" height="32"/>
|
||||
<subviews>
|
||||
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="accountNewsBlur" translatesAutoresizingMaskIntoConstraints="NO" id="6Tf-XJ-1e0">
|
||||
<rect key="frame" x="0.0" y="0.0" width="32" height="32"/>
|
||||
<color key="tintColor" systemColor="labelColor" cocoaTouchSystemColor="darkTextColor"/>
|
||||
<constraints>
|
||||
<constraint firstAttribute="width" constant="32" id="Bhm-KX-Sch"/>
|
||||
<constraint firstAttribute="height" constant="32" id="sFc-DJ-NBg"/>
|
||||
</constraints>
|
||||
</imageView>
|
||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="NewsBlur" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontForContentSizeCategory="YES" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="lKr-Le-Atw">
|
||||
<rect key="frame" x="48" y="0.0" width="116" height="32"/>
|
||||
<fontDescription key="fontDescription" style="UICTFontTextStyleTitle1"/>
|
||||
<nil key="textColor"/>
|
||||
<nil key="highlightedColor"/>
|
||||
</label>
|
||||
</subviews>
|
||||
</stackView>
|
||||
</subviews>
|
||||
<constraints>
|
||||
<constraint firstItem="PJ5-Pm-b2p" firstAttribute="centerY" secondItem="rSE-Cm-Oom" secondAttribute="centerY" id="4Zs-Lm-lmM"/>
|
||||
<constraint firstItem="PJ5-Pm-b2p" firstAttribute="leading" secondItem="rSE-Cm-Oom" secondAttribute="leading" constant="20" symbolic="YES" id="tDb-Wo-OOG"/>
|
||||
</constraints>
|
||||
</tableViewCellContentView>
|
||||
<connections>
|
||||
<outlet property="accountImage" destination="6Tf-XJ-1e0" id="PGF-56-QEs"/>
|
||||
<outlet property="accountNameLabel" destination="lKr-Le-Atw" id="g8z-Fb-JVk"/>
|
||||
</connections>
|
||||
</tableViewCell>
|
||||
</cells>
|
||||
</tableViewSection>
|
||||
</sections>
|
||||
</prototypes>
|
||||
<sections/>
|
||||
<connections>
|
||||
<outlet property="dataSource" destination="b00-4A-bV6" id="08h-4u-ZgK"/>
|
||||
<outlet property="delegate" destination="b00-4A-bV6" id="FKM-rN-deu"/>
|
||||
</connections>
|
||||
</tableView>
|
||||
<connections>
|
||||
<outlet property="localAccountImageView" destination="tb2-dO-AhR" id="PCa-g7-grR"/>
|
||||
<outlet property="localAccountNameLabel" destination="116-rt-msI" id="h6M-5V-392"/>
|
||||
</connections>
|
||||
</tableViewController>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="kmn-Q7-rga" userLabel="First Responder" sceneMemberID="firstResponder"/>
|
||||
</objects>
|
||||
|
@ -1083,11 +928,7 @@
|
|||
</scene>
|
||||
</scenes>
|
||||
<resources>
|
||||
<image name="accountFeedWrangler" width="512" height="512"/>
|
||||
<image name="accountFeedbin" width="120" height="102"/>
|
||||
<image name="accountFeedly" width="138" height="123"/>
|
||||
<image name="accountLocal" width="99" height="77"/>
|
||||
<image name="accountNewsBlur" width="512" height="512"/>
|
||||
<namedColor name="primaryAccentColor">
|
||||
<color red="0.031372549019607843" green="0.41568627450980394" blue="0.93333333333333335" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
||||
</namedColor>
|
||||
|
|
|
@ -26,7 +26,6 @@ class InteractiveNavigationController: UINavigationController {
|
|||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
poppableDelegate.originalDelegate = interactivePopGestureRecognizer?.delegate
|
||||
poppableDelegate.navigationController = self
|
||||
interactivePopGestureRecognizer?.delegate = poppableDelegate
|
||||
}
|
||||
|
|
|
@ -5,36 +5,20 @@
|
|||
// Created by Maurice Parker on 11/18/19.
|
||||
// Copyright © 2019 Ranchero Software. All rights reserved.
|
||||
//
|
||||
// https://stackoverflow.com/a/38042863
|
||||
// https://stackoverflow.com/a/41248703
|
||||
|
||||
import UIKit
|
||||
|
||||
final class PoppableGestureRecognizerDelegate: NSObject, UIGestureRecognizerDelegate {
|
||||
|
||||
weak var navigationController: UINavigationController?
|
||||
weak var originalDelegate: UIGestureRecognizerDelegate?
|
||||
|
||||
override func responds(to aSelector: Selector!) -> Bool {
|
||||
if aSelector == #selector(gestureRecognizer(_:shouldReceive:)) {
|
||||
func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
|
||||
return navigationController?.viewControllers.count ?? 0 > 1
|
||||
}
|
||||
|
||||
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
|
||||
return true
|
||||
} else if let responds = originalDelegate?.responds(to: aSelector) {
|
||||
return responds
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
override func forwardingTarget(for aSelector: Selector!) -> Any? {
|
||||
return originalDelegate
|
||||
}
|
||||
|
||||
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool {
|
||||
if let nav = navigationController, nav.isNavigationBarHidden, nav.viewControllers.count > 1 {
|
||||
return true
|
||||
} else if let result = originalDelegate?.gestureRecognizer?(gestureRecognizer, shouldReceive: touch) {
|
||||
return result
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1 +1 @@
|
|||
Subproject commit a175db5009f8222fcbaa825d9501305e8727da6f
|
||||
Subproject commit a742db73c4f4007f0d7097746c88ce2074400045
|
|
@ -1 +1 @@
|
|||
Subproject commit 47ba87875fbd026dccc2c4d4382a98cb4a1f1fbc
|
||||
Subproject commit a977d8e84af8645fc8268ac843e8a79b3644b133
|
|
@ -1 +1 @@
|
|||
Subproject commit aa4fda94f0e81809ac23de4040512378132f9e5d
|
||||
Subproject commit 88d634f5fd42aab203b6e53c7b551a92b03ffc97
|
Loading…
Reference in New Issue