Merge pull request #1908 from quanganhdo/newsblur-support
Newsblur support
This commit is contained in:
commit
c41c551093
@ -243,6 +243,8 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container,
|
|||||||
self.delegate = FeedlyAccountDelegate(dataFolder: dataFolder, transport: transport, api: FeedlyAccountDelegate.environment)
|
self.delegate = FeedlyAccountDelegate(dataFolder: dataFolder, transport: transport, api: FeedlyAccountDelegate.environment)
|
||||||
case .feedWrangler:
|
case .feedWrangler:
|
||||||
self.delegate = FeedWranglerAccountDelegate(dataFolder: dataFolder, transport: transport)
|
self.delegate = FeedWranglerAccountDelegate(dataFolder: dataFolder, transport: transport)
|
||||||
|
case .newsBlur:
|
||||||
|
self.delegate = NewsBlurAccountDelegate(dataFolder: dataFolder, transport: transport)
|
||||||
default:
|
default:
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@ -325,6 +327,8 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container,
|
|||||||
ReaderAPIAccountDelegate.validateCredentials(transport: transport, credentials: credentials, endpoint: endpoint, completion: completion)
|
ReaderAPIAccountDelegate.validateCredentials(transport: transport, credentials: credentials, endpoint: endpoint, completion: completion)
|
||||||
case .feedWrangler:
|
case .feedWrangler:
|
||||||
FeedWranglerAccountDelegate.validateCredentials(transport: transport, credentials: credentials, completion: completion)
|
FeedWranglerAccountDelegate.validateCredentials(transport: transport, credentials: credentials, completion: completion)
|
||||||
|
case .newsBlur:
|
||||||
|
NewsBlurAccountDelegate.validateCredentials(transport: transport, credentials: credentials, completion: completion)
|
||||||
default:
|
default:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
@ -7,6 +7,16 @@
|
|||||||
objects = {
|
objects = {
|
||||||
|
|
||||||
/* Begin PBXBuildFile section */
|
/* Begin PBXBuildFile section */
|
||||||
|
179DB02FFBC17AC9798F0EBC /* NewsBlurStory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 179DB7399814F6FB3247825C /* NewsBlurStory.swift */; };
|
||||||
|
179DB0B17A6C51B95ABC1741 /* NewsBlurStoryStatusChange.swift in Sources */ = {isa = PBXBuildFile; fileRef = 179DB5B421C5433B45C5F13E /* NewsBlurStoryStatusChange.swift */; };
|
||||||
|
179DB28CF49F73A945EBF5DB /* NewsBlurLoginResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 179DB088236E3236010462E8 /* NewsBlurLoginResponse.swift */; };
|
||||||
|
179DB3A93E3205EF29C2AF62 /* NewsBlurAPICaller+Internal.swift in Sources */ = {isa = PBXBuildFile; fileRef = 179DBBF346CF712AB2F0E9E6 /* NewsBlurAPICaller+Internal.swift */; };
|
||||||
|
179DB49A960F8B78C4924458 /* NewsBlurGenericCodingKeys.swift in Sources */ = {isa = PBXBuildFile; fileRef = 179DB66D933E976C29159DEE /* NewsBlurGenericCodingKeys.swift */; };
|
||||||
|
179DBCB4B11C88EBE852A015 /* NewsBlurFeedChange.swift in Sources */ = {isa = PBXBuildFile; fileRef = 179DB3CBADAFCF5377DA3D02 /* NewsBlurFeedChange.swift */; };
|
||||||
|
179DBD4ECC1C9712DF51DB8C /* NewsBlurFolderChange.swift in Sources */ = {isa = PBXBuildFile; fileRef = 179DBDDC00B68411AA28941F /* NewsBlurFolderChange.swift */; };
|
||||||
|
179DBE829FDF48E102F73244 /* NewsBlurAccountDelegate+Internal.swift in Sources */ = {isa = PBXBuildFile; fileRef = 179DB78C47256A122A281942 /* NewsBlurAccountDelegate+Internal.swift */; };
|
||||||
|
179DBED55C9B4D6A413486C1 /* NewsBlurStoryHash.swift in Sources */ = {isa = PBXBuildFile; fileRef = 179DB818180A51098A9816B2 /* NewsBlurStoryHash.swift */; };
|
||||||
|
179DBF4DE2562D4C532F6008 /* NewsBlurFeed.swift in Sources */ = {isa = PBXBuildFile; fileRef = 179DB1B909672E0E807B5E8C /* NewsBlurFeed.swift */; };
|
||||||
3B3A33E7238D3D6800314204 /* Secrets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B3A33E6238D3D6800314204 /* Secrets.swift */; };
|
3B3A33E7238D3D6800314204 /* Secrets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B3A33E6238D3D6800314204 /* Secrets.swift */; };
|
||||||
3B826DA72385C81C00FC1ADB /* FeedWranglerAuthorizationResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B826D9E2385C81C00FC1ADB /* FeedWranglerAuthorizationResult.swift */; };
|
3B826DA72385C81C00FC1ADB /* FeedWranglerAuthorizationResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B826D9E2385C81C00FC1ADB /* FeedWranglerAuthorizationResult.swift */; };
|
||||||
3B826DA82385C81C00FC1ADB /* FeedWranglerFeedItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B826D9F2385C81C00FC1ADB /* FeedWranglerFeedItem.swift */; };
|
3B826DA82385C81C00FC1ADB /* FeedWranglerFeedItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B826D9F2385C81C00FC1ADB /* FeedWranglerFeedItem.swift */; };
|
||||||
@ -63,6 +73,8 @@
|
|||||||
552032FD229D5D5A009559E0 /* ReaderAPITagging.swift in Sources */ = {isa = PBXBuildFile; fileRef = 552032F2229D5D5A009559E0 /* ReaderAPITagging.swift */; };
|
552032FD229D5D5A009559E0 /* ReaderAPITagging.swift in Sources */ = {isa = PBXBuildFile; fileRef = 552032F2229D5D5A009559E0 /* ReaderAPITagging.swift */; };
|
||||||
552032FE229D5D5A009559E0 /* ReaderAPIAccountDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 552032F3229D5D5A009559E0 /* ReaderAPIAccountDelegate.swift */; };
|
552032FE229D5D5A009559E0 /* ReaderAPIAccountDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 552032F3229D5D5A009559E0 /* ReaderAPIAccountDelegate.swift */; };
|
||||||
55203300229D5D5A009559E0 /* ReaderAPICaller.swift in Sources */ = {isa = PBXBuildFile; fileRef = 552032F5229D5D5A009559E0 /* ReaderAPICaller.swift */; };
|
55203300229D5D5A009559E0 /* ReaderAPICaller.swift in Sources */ = {isa = PBXBuildFile; fileRef = 552032F5229D5D5A009559E0 /* ReaderAPICaller.swift */; };
|
||||||
|
769F295938E5A30D03DFF88F /* NewsBlurAccountDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 769F2A8DF190549E24B5D110 /* NewsBlurAccountDelegate.swift */; };
|
||||||
|
769F2BA02EF5F329CDE45F5A /* NewsBlurAPICaller.swift in Sources */ = {isa = PBXBuildFile; fileRef = 769F275FD5D942502C5B4716 /* NewsBlurAPICaller.swift */; };
|
||||||
841973FE1F6DD1BC006346C4 /* RSCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 841973EF1F6DD19E006346C4 /* RSCore.framework */; };
|
841973FE1F6DD1BC006346C4 /* RSCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 841973EF1F6DD19E006346C4 /* RSCore.framework */; };
|
||||||
841973FF1F6DD1C5006346C4 /* RSParser.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 841973FA1F6DD1AC006346C4 /* RSParser.framework */; };
|
841973FF1F6DD1C5006346C4 /* RSParser.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 841973FA1F6DD1AC006346C4 /* RSParser.framework */; };
|
||||||
841974011F6DD1EC006346C4 /* Folder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 841974001F6DD1EC006346C4 /* Folder.swift */; };
|
841974011F6DD1EC006346C4 /* Folder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 841974001F6DD1EC006346C4 /* Folder.swift */; };
|
||||||
@ -220,6 +232,16 @@
|
|||||||
/* End PBXContainerItemProxy section */
|
/* End PBXContainerItemProxy section */
|
||||||
|
|
||||||
/* Begin PBXFileReference section */
|
/* Begin PBXFileReference section */
|
||||||
|
179DB088236E3236010462E8 /* NewsBlurLoginResponse.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NewsBlurLoginResponse.swift; sourceTree = "<group>"; };
|
||||||
|
179DB1B909672E0E807B5E8C /* NewsBlurFeed.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NewsBlurFeed.swift; sourceTree = "<group>"; };
|
||||||
|
179DB3CBADAFCF5377DA3D02 /* NewsBlurFeedChange.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NewsBlurFeedChange.swift; sourceTree = "<group>"; };
|
||||||
|
179DB5B421C5433B45C5F13E /* NewsBlurStoryStatusChange.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NewsBlurStoryStatusChange.swift; sourceTree = "<group>"; };
|
||||||
|
179DB66D933E976C29159DEE /* NewsBlurGenericCodingKeys.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NewsBlurGenericCodingKeys.swift; sourceTree = "<group>"; };
|
||||||
|
179DB7399814F6FB3247825C /* NewsBlurStory.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NewsBlurStory.swift; sourceTree = "<group>"; };
|
||||||
|
179DB78C47256A122A281942 /* NewsBlurAccountDelegate+Internal.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "NewsBlurAccountDelegate+Internal.swift"; sourceTree = "<group>"; };
|
||||||
|
179DB818180A51098A9816B2 /* NewsBlurStoryHash.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NewsBlurStoryHash.swift; sourceTree = "<group>"; };
|
||||||
|
179DBBF346CF712AB2F0E9E6 /* NewsBlurAPICaller+Internal.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "NewsBlurAPICaller+Internal.swift"; sourceTree = "<group>"; };
|
||||||
|
179DBDDC00B68411AA28941F /* NewsBlurFolderChange.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NewsBlurFolderChange.swift; sourceTree = "<group>"; };
|
||||||
3B3A33E6238D3D6800314204 /* Secrets.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = Secrets.swift; path = ../../Shared/Secrets.swift; sourceTree = "<group>"; };
|
3B3A33E6238D3D6800314204 /* Secrets.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = Secrets.swift; path = ../../Shared/Secrets.swift; sourceTree = "<group>"; };
|
||||||
3B826D9E2385C81C00FC1ADB /* FeedWranglerAuthorizationResult.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FeedWranglerAuthorizationResult.swift; sourceTree = "<group>"; };
|
3B826D9E2385C81C00FC1ADB /* FeedWranglerAuthorizationResult.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FeedWranglerAuthorizationResult.swift; sourceTree = "<group>"; };
|
||||||
3B826D9F2385C81C00FC1ADB /* FeedWranglerFeedItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FeedWranglerFeedItem.swift; sourceTree = "<group>"; };
|
3B826D9F2385C81C00FC1ADB /* FeedWranglerFeedItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FeedWranglerFeedItem.swift; sourceTree = "<group>"; };
|
||||||
@ -278,6 +300,8 @@
|
|||||||
552032F2229D5D5A009559E0 /* ReaderAPITagging.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReaderAPITagging.swift; sourceTree = "<group>"; };
|
552032F2229D5D5A009559E0 /* ReaderAPITagging.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReaderAPITagging.swift; sourceTree = "<group>"; };
|
||||||
552032F3229D5D5A009559E0 /* ReaderAPIAccountDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReaderAPIAccountDelegate.swift; sourceTree = "<group>"; };
|
552032F3229D5D5A009559E0 /* ReaderAPIAccountDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReaderAPIAccountDelegate.swift; sourceTree = "<group>"; };
|
||||||
552032F5229D5D5A009559E0 /* ReaderAPICaller.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReaderAPICaller.swift; sourceTree = "<group>"; };
|
552032F5229D5D5A009559E0 /* ReaderAPICaller.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReaderAPICaller.swift; sourceTree = "<group>"; };
|
||||||
|
769F275FD5D942502C5B4716 /* NewsBlurAPICaller.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NewsBlurAPICaller.swift; sourceTree = "<group>"; };
|
||||||
|
769F2A8DF190549E24B5D110 /* NewsBlurAccountDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NewsBlurAccountDelegate.swift; sourceTree = "<group>"; };
|
||||||
841973E81F6DD19E006346C4 /* RSCore.xcodeproj */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.pb-project"; name = RSCore.xcodeproj; path = ../RSCore/RSCore.xcodeproj; sourceTree = "<group>"; };
|
841973E81F6DD19E006346C4 /* RSCore.xcodeproj */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.pb-project"; name = RSCore.xcodeproj; path = ../RSCore/RSCore.xcodeproj; sourceTree = "<group>"; };
|
||||||
841973F41F6DD1AC006346C4 /* RSParser.xcodeproj */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.pb-project"; name = RSParser.xcodeproj; path = ../RSParser/RSParser.xcodeproj; sourceTree = "<group>"; };
|
841973F41F6DD1AC006346C4 /* RSParser.xcodeproj */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.pb-project"; name = RSParser.xcodeproj; path = ../RSParser/RSParser.xcodeproj; sourceTree = "<group>"; };
|
||||||
841974001F6DD1EC006346C4 /* Folder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Folder.swift; sourceTree = "<group>"; };
|
841974001F6DD1EC006346C4 /* Folder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Folder.swift; sourceTree = "<group>"; };
|
||||||
@ -435,6 +459,30 @@
|
|||||||
/* End PBXFrameworksBuildPhase section */
|
/* End PBXFrameworksBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXGroup section */
|
/* Begin PBXGroup section */
|
||||||
|
179DB1571B95BAD0F833AF6D /* Internals */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
179DB78C47256A122A281942 /* NewsBlurAccountDelegate+Internal.swift */,
|
||||||
|
179DBBF346CF712AB2F0E9E6 /* NewsBlurAPICaller+Internal.swift */,
|
||||||
|
);
|
||||||
|
path = Internals;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
179DBD810D353D9CED7C3BED /* Models */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
179DB088236E3236010462E8 /* NewsBlurLoginResponse.swift */,
|
||||||
|
179DB1B909672E0E807B5E8C /* NewsBlurFeed.swift */,
|
||||||
|
179DB7399814F6FB3247825C /* NewsBlurStory.swift */,
|
||||||
|
179DB66D933E976C29159DEE /* NewsBlurGenericCodingKeys.swift */,
|
||||||
|
179DB818180A51098A9816B2 /* NewsBlurStoryHash.swift */,
|
||||||
|
179DB5B421C5433B45C5F13E /* NewsBlurStoryStatusChange.swift */,
|
||||||
|
179DBDDC00B68411AA28941F /* NewsBlurFolderChange.swift */,
|
||||||
|
179DB3CBADAFCF5377DA3D02 /* NewsBlurFeedChange.swift */,
|
||||||
|
);
|
||||||
|
path = Models;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
3B826D9D2385C81C00FC1ADB /* FeedWrangler */ = {
|
3B826D9D2385C81C00FC1ADB /* FeedWrangler */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
@ -523,6 +571,17 @@
|
|||||||
path = ReaderAPI;
|
path = ReaderAPI;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
769F2630AF8DC873D4A73567 /* NewsBlur */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
769F2A8DF190549E24B5D110 /* NewsBlurAccountDelegate.swift */,
|
||||||
|
769F275FD5D942502C5B4716 /* NewsBlurAPICaller.swift */,
|
||||||
|
179DBD810D353D9CED7C3BED /* Models */,
|
||||||
|
179DB1571B95BAD0F833AF6D /* Internals */,
|
||||||
|
);
|
||||||
|
path = NewsBlur;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
841973E91F6DD19E006346C4 /* Products */ = {
|
841973E91F6DD19E006346C4 /* Products */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
@ -622,6 +681,7 @@
|
|||||||
8469F80F1F6DC3C10084783E /* Frameworks */,
|
8469F80F1F6DC3C10084783E /* Frameworks */,
|
||||||
D511EEB4202422BB00712EC3 /* xcconfig */,
|
D511EEB4202422BB00712EC3 /* xcconfig */,
|
||||||
848934FA1F62484F00CEBD24 /* Info.plist */,
|
848934FA1F62484F00CEBD24 /* Info.plist */,
|
||||||
|
769F2630AF8DC873D4A73567 /* NewsBlur */,
|
||||||
);
|
);
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
usesTabs = 1;
|
usesTabs = 1;
|
||||||
@ -1107,6 +1167,18 @@
|
|||||||
9EF2602C23C91FFE006D160C /* FeedlyGetUpdatedArticleIdsOperation.swift in Sources */,
|
9EF2602C23C91FFE006D160C /* FeedlyGetUpdatedArticleIdsOperation.swift in Sources */,
|
||||||
3B826DAA2385C81C00FC1ADB /* FeedWranglerSubscription.swift in Sources */,
|
3B826DAA2385C81C00FC1ADB /* FeedWranglerSubscription.swift in Sources */,
|
||||||
3B826DAC2385C81C00FC1ADB /* FeedWranglerAccountDelegate.swift in Sources */,
|
3B826DAC2385C81C00FC1ADB /* FeedWranglerAccountDelegate.swift in Sources */,
|
||||||
|
769F295938E5A30D03DFF88F /* NewsBlurAccountDelegate.swift in Sources */,
|
||||||
|
769F2BA02EF5F329CDE45F5A /* NewsBlurAPICaller.swift in Sources */,
|
||||||
|
179DB28CF49F73A945EBF5DB /* NewsBlurLoginResponse.swift in Sources */,
|
||||||
|
179DBF4DE2562D4C532F6008 /* NewsBlurFeed.swift in Sources */,
|
||||||
|
179DB02FFBC17AC9798F0EBC /* NewsBlurStory.swift in Sources */,
|
||||||
|
179DB49A960F8B78C4924458 /* NewsBlurGenericCodingKeys.swift in Sources */,
|
||||||
|
179DBED55C9B4D6A413486C1 /* NewsBlurStoryHash.swift in Sources */,
|
||||||
|
179DB0B17A6C51B95ABC1741 /* NewsBlurStoryStatusChange.swift in Sources */,
|
||||||
|
179DBD4ECC1C9712DF51DB8C /* NewsBlurFolderChange.swift in Sources */,
|
||||||
|
179DBCB4B11C88EBE852A015 /* NewsBlurFeedChange.swift in Sources */,
|
||||||
|
179DBE829FDF48E102F73244 /* NewsBlurAccountDelegate+Internal.swift in Sources */,
|
||||||
|
179DB3A93E3205EF29C2AF62 /* NewsBlurAPICaller+Internal.swift in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
@ -17,6 +17,8 @@ public enum CredentialsType: String {
|
|||||||
case basic = "password"
|
case basic = "password"
|
||||||
case feedWranglerBasic = "feedWranglerBasic"
|
case feedWranglerBasic = "feedWranglerBasic"
|
||||||
case feedWranglerToken = "feedWranglerToken"
|
case feedWranglerToken = "feedWranglerToken"
|
||||||
|
case newsBlurBasic = "newsBlurBasic"
|
||||||
|
case newsBlurSessionId = "newsBlurSessionId"
|
||||||
case readerBasic = "readerBasic"
|
case readerBasic = "readerBasic"
|
||||||
case readerAPIKey = "readerAPIKey"
|
case readerAPIKey = "readerAPIKey"
|
||||||
case oauthAccessToken = "oauthAccessToken"
|
case oauthAccessToken = "oauthAccessToken"
|
||||||
|
@ -33,6 +33,18 @@ public extension URLRequest {
|
|||||||
])
|
])
|
||||||
case .feedWranglerToken:
|
case .feedWranglerToken:
|
||||||
self.url = url.appendingQueryItem(URLQueryItem(name: "access_token", value: credentials.secret))
|
self.url = url.appendingQueryItem(URLQueryItem(name: "access_token", value: credentials.secret))
|
||||||
|
case .newsBlurBasic:
|
||||||
|
setValue("application/x-www-form-urlencoded", forHTTPHeaderField: HTTPRequestHeader.contentType)
|
||||||
|
httpMethod = "POST"
|
||||||
|
var postData = URLComponents()
|
||||||
|
postData.queryItems = [
|
||||||
|
URLQueryItem(name: "username", value: credentials.username),
|
||||||
|
URLQueryItem(name: "password", value: credentials.secret),
|
||||||
|
]
|
||||||
|
httpBody = postData.percentEncodedQuery?.data(using: .utf8)
|
||||||
|
case .newsBlurSessionId:
|
||||||
|
setValue("\(NewsBlurAPICaller.SessionIdCookie)=\(credentials.secret)", forHTTPHeaderField: "Cookie")
|
||||||
|
httpShouldHandleCookies = true
|
||||||
case .readerBasic:
|
case .readerBasic:
|
||||||
setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
|
setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
|
||||||
httpMethod = "POST"
|
httpMethod = "POST"
|
||||||
|
@ -0,0 +1,236 @@
|
|||||||
|
//
|
||||||
|
// NewsBlurAPICaller+Internal.swift
|
||||||
|
// Account
|
||||||
|
//
|
||||||
|
// Created by Anh Quang Do on 2020-03-21.
|
||||||
|
// Copyright (c) 2020 Ranchero Software, LLC. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import RSWeb
|
||||||
|
|
||||||
|
protocol NewsBlurDataConvertible {
|
||||||
|
var asData: Data? { get }
|
||||||
|
}
|
||||||
|
|
||||||
|
enum NewsBlurError: LocalizedError {
|
||||||
|
case general(message: String)
|
||||||
|
case invalidParameter
|
||||||
|
case unknown
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .general(let message):
|
||||||
|
return message
|
||||||
|
case .invalidParameter:
|
||||||
|
return "There was an invalid parameter passed"
|
||||||
|
case .unknown:
|
||||||
|
return "An unknown error occurred"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Interact with endpoints
|
||||||
|
|
||||||
|
extension NewsBlurAPICaller {
|
||||||
|
// GET endpoint, discard response
|
||||||
|
func requestData(
|
||||||
|
endpoint: String,
|
||||||
|
completion: @escaping (Result<Void, Error>) -> Void
|
||||||
|
) {
|
||||||
|
let callURL = baseURL.appendingPathComponent(endpoint)
|
||||||
|
|
||||||
|
requestData(callURL: callURL, completion: completion)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET endpoint
|
||||||
|
func requestData<R: Decodable>(
|
||||||
|
endpoint: String,
|
||||||
|
resultType: R.Type,
|
||||||
|
dateDecoding: JSONDecoder.DateDecodingStrategy = .iso8601,
|
||||||
|
keyDecoding: JSONDecoder.KeyDecodingStrategy = .useDefaultKeys,
|
||||||
|
completion: @escaping (Result<(HTTPURLResponse, R?), Error>) -> Void
|
||||||
|
) {
|
||||||
|
let callURL = baseURL.appendingPathComponent(endpoint)
|
||||||
|
|
||||||
|
requestData(
|
||||||
|
callURL: callURL,
|
||||||
|
resultType: resultType,
|
||||||
|
dateDecoding: dateDecoding,
|
||||||
|
keyDecoding: keyDecoding,
|
||||||
|
completion: completion
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST to endpoint, discard response
|
||||||
|
func sendUpdates(
|
||||||
|
endpoint: String,
|
||||||
|
payload: NewsBlurDataConvertible,
|
||||||
|
completion: @escaping (Result<Void, Error>) -> Void
|
||||||
|
) {
|
||||||
|
let callURL = baseURL.appendingPathComponent(endpoint)
|
||||||
|
|
||||||
|
sendUpdates(callURL: callURL, payload: payload, completion: completion)
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST to endpoint
|
||||||
|
func sendUpdates<R: Decodable>(
|
||||||
|
endpoint: String,
|
||||||
|
payload: NewsBlurDataConvertible,
|
||||||
|
resultType: R.Type,
|
||||||
|
dateDecoding: JSONDecoder.DateDecodingStrategy = .iso8601,
|
||||||
|
keyDecoding: JSONDecoder.KeyDecodingStrategy = .useDefaultKeys,
|
||||||
|
completion: @escaping (Result<(HTTPURLResponse, R?), Error>) -> Void
|
||||||
|
) {
|
||||||
|
let callURL = baseURL.appendingPathComponent(endpoint)
|
||||||
|
|
||||||
|
sendUpdates(
|
||||||
|
callURL: callURL,
|
||||||
|
payload: payload,
|
||||||
|
resultType: resultType,
|
||||||
|
dateDecoding: dateDecoding,
|
||||||
|
keyDecoding: keyDecoding,
|
||||||
|
completion: completion
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Interact with URLs
|
||||||
|
|
||||||
|
extension NewsBlurAPICaller {
|
||||||
|
// GET URL with params, discard response
|
||||||
|
func requestData(
|
||||||
|
callURL: URL?,
|
||||||
|
completion: @escaping (Result<Void, Error>) -> Void
|
||||||
|
) {
|
||||||
|
guard let callURL = callURL else {
|
||||||
|
completion(.failure(TransportError.noURL))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let request = URLRequest(url: callURL, credentials: credentials)
|
||||||
|
|
||||||
|
transport.send(request: request) { result in
|
||||||
|
if self.suspended {
|
||||||
|
completion(.failure(TransportError.suspended))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch result {
|
||||||
|
case .success:
|
||||||
|
completion(.success(()))
|
||||||
|
case .failure(let error):
|
||||||
|
completion(.failure(error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET URL with params
|
||||||
|
func requestData<R: Decodable>(
|
||||||
|
callURL: URL?,
|
||||||
|
resultType: R.Type,
|
||||||
|
dateDecoding: JSONDecoder.DateDecodingStrategy = .iso8601,
|
||||||
|
keyDecoding: JSONDecoder.KeyDecodingStrategy = .useDefaultKeys,
|
||||||
|
completion: @escaping (Result<(HTTPURLResponse, R?), Error>) -> Void
|
||||||
|
) {
|
||||||
|
guard let callURL = callURL else {
|
||||||
|
completion(.failure(TransportError.noURL))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let request = URLRequest(url: callURL, credentials: credentials)
|
||||||
|
|
||||||
|
transport.send(
|
||||||
|
request: request,
|
||||||
|
resultType: resultType,
|
||||||
|
dateDecoding: dateDecoding,
|
||||||
|
keyDecoding: keyDecoding
|
||||||
|
) { result in
|
||||||
|
if self.suspended {
|
||||||
|
completion(.failure(TransportError.suspended))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch result {
|
||||||
|
case .success(let response):
|
||||||
|
completion(.success(response))
|
||||||
|
case .failure(let error):
|
||||||
|
completion(.failure(error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST to URL with params, discard response
|
||||||
|
func sendUpdates(
|
||||||
|
callURL: URL?,
|
||||||
|
payload: NewsBlurDataConvertible,
|
||||||
|
completion: @escaping (Result<Void, Error>) -> Void
|
||||||
|
) {
|
||||||
|
guard let callURL = callURL else {
|
||||||
|
completion(.failure(TransportError.noURL))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var request = URLRequest(url: callURL, credentials: credentials)
|
||||||
|
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: HTTPRequestHeader.contentType)
|
||||||
|
request.httpBody = payload.asData
|
||||||
|
|
||||||
|
transport.send(request: request, method: HTTPMethod.post) { result in
|
||||||
|
if self.suspended {
|
||||||
|
completion(.failure(TransportError.suspended))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch result {
|
||||||
|
case .success:
|
||||||
|
completion(.success(()))
|
||||||
|
case .failure(let error):
|
||||||
|
completion(.failure(error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST to URL with params
|
||||||
|
func sendUpdates<R: Decodable>(
|
||||||
|
callURL: URL?,
|
||||||
|
payload: NewsBlurDataConvertible,
|
||||||
|
resultType: R.Type,
|
||||||
|
dateDecoding: JSONDecoder.DateDecodingStrategy = .iso8601,
|
||||||
|
keyDecoding: JSONDecoder.KeyDecodingStrategy = .useDefaultKeys,
|
||||||
|
completion: @escaping (Result<(HTTPURLResponse, R?), Error>) -> Void
|
||||||
|
) {
|
||||||
|
guard let callURL = callURL else {
|
||||||
|
completion(.failure(TransportError.noURL))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let data = payload.asData else {
|
||||||
|
completion(.failure(NewsBlurError.invalidParameter))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var request = URLRequest(url: callURL, credentials: credentials)
|
||||||
|
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: HTTPRequestHeader.contentType)
|
||||||
|
|
||||||
|
transport.send(
|
||||||
|
request: request,
|
||||||
|
method: HTTPMethod.post,
|
||||||
|
data: data,
|
||||||
|
resultType: resultType,
|
||||||
|
dateDecoding: dateDecoding,
|
||||||
|
keyDecoding: keyDecoding
|
||||||
|
) { result in
|
||||||
|
if self.suspended {
|
||||||
|
completion(.failure(TransportError.suspended))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch result {
|
||||||
|
case .success(let response):
|
||||||
|
completion(.success(response))
|
||||||
|
case .failure(let error):
|
||||||
|
completion(.failure(error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,519 @@
|
|||||||
|
//
|
||||||
|
// NewsBlurAccountDelegate+Internal.swift
|
||||||
|
// Mostly adapted from FeedbinAccountDelegate.swift
|
||||||
|
// Account
|
||||||
|
//
|
||||||
|
// Created by Anh Quang Do on 2020-03-14.
|
||||||
|
// Copyright (c) 2020 Ranchero Software, LLC. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Articles
|
||||||
|
import RSCore
|
||||||
|
import RSDatabase
|
||||||
|
import RSParser
|
||||||
|
import RSWeb
|
||||||
|
import SyncDatabase
|
||||||
|
import os.log
|
||||||
|
|
||||||
|
extension NewsBlurAccountDelegate {
|
||||||
|
func refreshFeeds(for account: Account, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||||
|
os_log(.debug, log: log, "Refreshing feeds...")
|
||||||
|
|
||||||
|
caller.retrieveFeeds { result in
|
||||||
|
switch result {
|
||||||
|
case .success((let feeds, let folders)):
|
||||||
|
BatchUpdate.shared.perform {
|
||||||
|
self.syncFolders(account, folders)
|
||||||
|
self.syncFeeds(account, feeds)
|
||||||
|
self.syncFeedFolderRelationship(account, folders)
|
||||||
|
}
|
||||||
|
|
||||||
|
self.refreshProgress.completeTask()
|
||||||
|
completion(.success(()))
|
||||||
|
case .failure(let error):
|
||||||
|
completion(.failure(error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func syncFolders(_ account: Account, _ folders: [NewsBlurFolder]?) {
|
||||||
|
guard let folders = folders else { return }
|
||||||
|
assert(Thread.isMainThread)
|
||||||
|
|
||||||
|
os_log(.debug, log: log, "Syncing folders with %ld folders.", folders.count)
|
||||||
|
|
||||||
|
let folderNames = folders.map { $0.name }
|
||||||
|
|
||||||
|
// Delete any folders not at NewsBlur
|
||||||
|
if let folders = account.folders {
|
||||||
|
folders.forEach { folder in
|
||||||
|
if !folderNames.contains(folder.name ?? "") {
|
||||||
|
for feed in folder.topLevelWebFeeds {
|
||||||
|
account.addWebFeed(feed)
|
||||||
|
clearFolderRelationship(for: feed, withFolderName: folder.name ?? "")
|
||||||
|
}
|
||||||
|
account.removeFolder(folder)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let accountFolderNames: [String] = {
|
||||||
|
if let folders = account.folders {
|
||||||
|
return folders.map { $0.name ?? "" }
|
||||||
|
} else {
|
||||||
|
return [String]()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Make any folders NewsBlur has, but we don't
|
||||||
|
// Ignore account-level folder
|
||||||
|
folderNames.forEach { folderName in
|
||||||
|
if !accountFolderNames.contains(folderName) && folderName != " " {
|
||||||
|
_ = account.ensureFolder(with: folderName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func syncFeeds(_ account: Account, _ feeds: [NewsBlurFeed]?) {
|
||||||
|
guard let feeds = feeds else { return }
|
||||||
|
assert(Thread.isMainThread)
|
||||||
|
|
||||||
|
os_log(.debug, log: log, "Syncing feeds with %ld feeds.", feeds.count)
|
||||||
|
|
||||||
|
let newsBlurFeedIds = feeds.map { String($0.feedID) }
|
||||||
|
|
||||||
|
// Remove any feeds that are no longer in the subscriptions
|
||||||
|
if let folders = account.folders {
|
||||||
|
for folder in folders {
|
||||||
|
for feed in folder.topLevelWebFeeds {
|
||||||
|
if !newsBlurFeedIds.contains(feed.webFeedID) {
|
||||||
|
folder.removeWebFeed(feed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for feed in account.topLevelWebFeeds {
|
||||||
|
if !newsBlurFeedIds.contains(feed.webFeedID) {
|
||||||
|
account.removeWebFeed(feed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add any feeds we don't have and update any we do
|
||||||
|
var feedsToAdd = Set<NewsBlurFeed>()
|
||||||
|
feeds.forEach { feed in
|
||||||
|
let subFeedId = String(feed.feedID)
|
||||||
|
|
||||||
|
if let webFeed = account.existingWebFeed(withWebFeedID: subFeedId) {
|
||||||
|
webFeed.name = feed.name
|
||||||
|
// If the name has been changed on the server remove the locally edited name
|
||||||
|
webFeed.editedName = nil
|
||||||
|
webFeed.homePageURL = feed.homePageURL
|
||||||
|
webFeed.externalID = String(feed.feedID)
|
||||||
|
webFeed.faviconURL = feed.faviconURL
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
feedsToAdd.insert(feed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Actually add feeds all in one go, so we don’t trigger various rebuilding things that Account does.
|
||||||
|
feedsToAdd.forEach { feed in
|
||||||
|
let webFeed = account.createWebFeed(with: feed.name, url: feed.feedURL, webFeedID: String(feed.feedID), homePageURL: feed.homePageURL)
|
||||||
|
webFeed.externalID = String(feed.feedID)
|
||||||
|
account.addWebFeed(webFeed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func syncFeedFolderRelationship(_ account: Account, _ folders: [NewsBlurFolder]?) {
|
||||||
|
guard let folders = folders else { return }
|
||||||
|
assert(Thread.isMainThread)
|
||||||
|
|
||||||
|
os_log(.debug, log: log, "Syncing folders with %ld folders.", folders.count)
|
||||||
|
|
||||||
|
// Set up some structures to make syncing easier
|
||||||
|
let relationships = folders.map({ $0.asRelationships }).flatMap { $0 }
|
||||||
|
let folderDict = nameToFolderDictionary(with: account.folders)
|
||||||
|
let newsBlurFolderDict = relationships.reduce([String: [NewsBlurFolderRelationship]]()) { (dict, relationship) in
|
||||||
|
var feedInFolders = dict
|
||||||
|
if var feedInFolder = feedInFolders[relationship.folderName] {
|
||||||
|
feedInFolder.append(relationship)
|
||||||
|
feedInFolders[relationship.folderName] = feedInFolder
|
||||||
|
} else {
|
||||||
|
feedInFolders[relationship.folderName] = [relationship]
|
||||||
|
}
|
||||||
|
return feedInFolders
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync the folders
|
||||||
|
for (folderName, folderRelationships) in newsBlurFolderDict {
|
||||||
|
let newsBlurFolderFeedIDs = folderRelationships.map { String($0.feedID) }
|
||||||
|
|
||||||
|
// Handle account-level folder
|
||||||
|
if folderName == " " {
|
||||||
|
for feed in account.topLevelWebFeeds {
|
||||||
|
if !newsBlurFolderFeedIDs.contains(feed.webFeedID) {
|
||||||
|
account.removeWebFeed(feed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let folder = folderDict[folderName] else { return }
|
||||||
|
|
||||||
|
// Move any feeds not in the folder to the account
|
||||||
|
for feed in folder.topLevelWebFeeds {
|
||||||
|
if !newsBlurFolderFeedIDs.contains(feed.webFeedID) {
|
||||||
|
folder.removeWebFeed(feed)
|
||||||
|
clearFolderRelationship(for: feed, withFolderName: folder.name ?? "")
|
||||||
|
account.addWebFeed(feed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add any feeds not in the folder
|
||||||
|
let folderFeedIds = folder.topLevelWebFeeds.map { $0.webFeedID }
|
||||||
|
|
||||||
|
for relationship in folderRelationships {
|
||||||
|
let folderFeedID = String(relationship.feedID)
|
||||||
|
if !folderFeedIds.contains(folderFeedID) {
|
||||||
|
guard let feed = account.existingWebFeed(withWebFeedID: folderFeedID) else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
saveFolderRelationship(for: feed, withFolderName: folderName, id: relationship.folderName)
|
||||||
|
folder.addWebFeed(feed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearFolderRelationship(for feed: WebFeed, withFolderName folderName: String) {
|
||||||
|
if var folderRelationship = feed.folderRelationship {
|
||||||
|
folderRelationship[folderName] = nil
|
||||||
|
feed.folderRelationship = folderRelationship
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func saveFolderRelationship(for feed: WebFeed, withFolderName folderName: String, id: String) {
|
||||||
|
if var folderRelationship = feed.folderRelationship {
|
||||||
|
folderRelationship[folderName] = id
|
||||||
|
feed.folderRelationship = folderRelationship
|
||||||
|
} else {
|
||||||
|
feed.folderRelationship = [folderName: id]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func nameToFolderDictionary(with folders: Set<Folder>?) -> [String: Folder] {
|
||||||
|
guard let folders = folders else {
|
||||||
|
return [String: Folder]()
|
||||||
|
}
|
||||||
|
|
||||||
|
var d = [String: Folder]()
|
||||||
|
for folder in folders {
|
||||||
|
let name = folder.name ?? ""
|
||||||
|
if d[name] == nil {
|
||||||
|
d[name] = folder
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
func refreshUnreadStories(for account: Account, hashes: [NewsBlurStoryHash]?, updateFetchDate: Date?, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||||
|
guard let hashes = hashes, !hashes.isEmpty else {
|
||||||
|
if let lastArticleFetch = updateFetchDate {
|
||||||
|
self.accountMetadata?.lastArticleFetchStartTime = lastArticleFetch
|
||||||
|
self.accountMetadata?.lastArticleFetchEndTime = Date()
|
||||||
|
}
|
||||||
|
completion(.success(()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let numberOfStories = min(hashes.count, 100) // api limit
|
||||||
|
let hashesToFetch = Array(hashes[..<numberOfStories])
|
||||||
|
|
||||||
|
caller.retrieveStories(hashes: hashesToFetch) { result in
|
||||||
|
switch result {
|
||||||
|
case .success((let stories, let date)):
|
||||||
|
self.processStories(account: account, stories: stories) { result in
|
||||||
|
self.refreshProgress.completeTask()
|
||||||
|
|
||||||
|
if case .failure(let error) = result {
|
||||||
|
completion(.failure(error))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
self.refreshUnreadStories(for: account, hashes: Array(hashes[numberOfStories...]), updateFetchDate: date) { result in
|
||||||
|
os_log(.debug, log: self.log, "Done refreshing stories.")
|
||||||
|
switch result {
|
||||||
|
case .success:
|
||||||
|
completion(.success(()))
|
||||||
|
case .failure(let error):
|
||||||
|
completion(.failure(error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case .failure(let error):
|
||||||
|
completion(.failure(error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mapStoriesToParsedItems(stories: [NewsBlurStory]?) -> Set<ParsedItem> {
|
||||||
|
guard let stories = stories else { return Set<ParsedItem>() }
|
||||||
|
|
||||||
|
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 Set(parsedItems)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendStoryStatuses(_ statuses: [SyncStatus],
|
||||||
|
throttle: Bool,
|
||||||
|
apiCall: ([String], @escaping (Result<Void, Error>) -> Void) -> Void,
|
||||||
|
completion: @escaping (Result<Void, Error>) -> Void) {
|
||||||
|
guard !statuses.isEmpty else {
|
||||||
|
completion(.success(()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let group = DispatchGroup()
|
||||||
|
var errorOccurred = false
|
||||||
|
|
||||||
|
let storyHashes = statuses.compactMap { $0.articleID }
|
||||||
|
let storyHashGroups = storyHashes.chunked(into: throttle ? 1 : 5) // api limit
|
||||||
|
for storyHashGroup in storyHashGroups {
|
||||||
|
group.enter()
|
||||||
|
apiCall(storyHashGroup) { result in
|
||||||
|
switch result {
|
||||||
|
case .success:
|
||||||
|
self.database.deleteSelectedForProcessing(storyHashGroup.map { String($0) } )
|
||||||
|
group.leave()
|
||||||
|
case .failure(let error):
|
||||||
|
errorOccurred = true
|
||||||
|
os_log(.error, log: self.log, "Story status sync call failed: %@.", error.localizedDescription)
|
||||||
|
self.database.resetSelectedForProcessing(storyHashGroup.map { String($0) } )
|
||||||
|
group.leave()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
group.notify(queue: DispatchQueue.main) {
|
||||||
|
if errorOccurred {
|
||||||
|
completion(.failure(NewsBlurError.unknown))
|
||||||
|
} else {
|
||||||
|
completion(.success(()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func syncStoryReadState(account: Account, hashes: [NewsBlurStoryHash]?) {
|
||||||
|
guard let hashes = hashes else { return }
|
||||||
|
|
||||||
|
database.selectPendingReadStatusArticleIDs() { result in
|
||||||
|
func process(_ pendingStoryHashes: Set<String>) {
|
||||||
|
|
||||||
|
let newsBlurUnreadStoryHashes = Set(hashes.map { $0.hash } )
|
||||||
|
let updatableNewsBlurUnreadStoryHashes = newsBlurUnreadStoryHashes.subtracting(pendingStoryHashes)
|
||||||
|
|
||||||
|
account.fetchUnreadArticleIDs { articleIDsResult in
|
||||||
|
guard let currentUnreadArticleIDs = try? articleIDsResult.get() else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark articles as unread
|
||||||
|
let deltaUnreadArticleIDs = updatableNewsBlurUnreadStoryHashes.subtracting(currentUnreadArticleIDs)
|
||||||
|
account.markAsUnread(deltaUnreadArticleIDs)
|
||||||
|
|
||||||
|
// Mark articles as read
|
||||||
|
let deltaReadArticleIDs = currentUnreadArticleIDs.subtracting(updatableNewsBlurUnreadStoryHashes)
|
||||||
|
account.markAsRead(deltaReadArticleIDs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch result {
|
||||||
|
case .success(let pendingArticleIDs):
|
||||||
|
process(pendingArticleIDs)
|
||||||
|
case .failure(let error):
|
||||||
|
os_log(.error, log: self.log, "Sync Story Read Status failed: %@.", error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func syncStoryStarredState(account: Account, hashes: [NewsBlurStoryHash]?) {
|
||||||
|
guard let hashes = hashes else { return }
|
||||||
|
|
||||||
|
database.selectPendingStarredStatusArticleIDs() { result in
|
||||||
|
func process(_ pendingStoryHashes: Set<String>) {
|
||||||
|
|
||||||
|
let newsBlurStarredStoryHashes = Set(hashes.map { $0.hash } )
|
||||||
|
let updatableNewsBlurUnreadStoryHashes = newsBlurStarredStoryHashes.subtracting(pendingStoryHashes)
|
||||||
|
|
||||||
|
account.fetchStarredArticleIDs { articleIDsResult in
|
||||||
|
guard let currentStarredArticleIDs = try? articleIDsResult.get() else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark articles as starred
|
||||||
|
let deltaStarredArticleIDs = updatableNewsBlurUnreadStoryHashes.subtracting(currentStarredArticleIDs)
|
||||||
|
account.markAsStarred(deltaStarredArticleIDs)
|
||||||
|
|
||||||
|
// Mark articles as unstarred
|
||||||
|
let deltaUnstarredArticleIDs = currentStarredArticleIDs.subtracting(updatableNewsBlurUnreadStoryHashes)
|
||||||
|
account.markAsUnstarred(deltaUnstarredArticleIDs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch result {
|
||||||
|
case .success(let pendingArticleIDs):
|
||||||
|
process(pendingArticleIDs)
|
||||||
|
case .failure(let error):
|
||||||
|
os_log(.error, log: self.log, "Sync Story Starred Status failed: %@.", error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func createFeed(account: Account, feed: NewsBlurFeed?, name: String?, container: Container, completion: @escaping (Result<WebFeed, Error>) -> Void) {
|
||||||
|
guard let feed = feed else {
|
||||||
|
completion(.failure(NewsBlurError.invalidParameter))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
let webFeed = account.createWebFeed(with: feed.name, url: feed.feedURL, webFeedID: String(feed.feedID), homePageURL: feed.homePageURL)
|
||||||
|
webFeed.externalID = String(feed.feedID)
|
||||||
|
webFeed.faviconURL = feed.faviconURL
|
||||||
|
|
||||||
|
account.addWebFeed(webFeed, to: container) { result in
|
||||||
|
switch result {
|
||||||
|
case .success:
|
||||||
|
if let name = name {
|
||||||
|
account.renameWebFeed(webFeed, to: name) { result in
|
||||||
|
switch result {
|
||||||
|
case .success:
|
||||||
|
self.initialFeedDownload(account: account, feed: webFeed, completion: completion)
|
||||||
|
case .failure(let error):
|
||||||
|
completion(.failure(error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
self.initialFeedDownload(account: account, feed: webFeed, completion: completion)
|
||||||
|
}
|
||||||
|
case .failure(let error):
|
||||||
|
completion(.failure(error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func downloadFeed(account: Account, feed: WebFeed, page: Int, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||||
|
refreshProgress.addToNumberOfTasksAndRemaining(1)
|
||||||
|
|
||||||
|
caller.retrieveStories(feedID: feed.webFeedID, page: page) { result in
|
||||||
|
switch result {
|
||||||
|
case .success((let stories, _)):
|
||||||
|
// No more stories
|
||||||
|
guard let stories = stories, stories.count > 0 else {
|
||||||
|
self.refreshProgress.completeTask()
|
||||||
|
|
||||||
|
completion(.success(()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let since: Date? = Calendar.current.date(byAdding: .month, value: -3, to: Date())
|
||||||
|
|
||||||
|
self.processStories(account: account, stories: stories, since: since) { result in
|
||||||
|
self.refreshProgress.completeTask()
|
||||||
|
|
||||||
|
if case .failure(let error) = result {
|
||||||
|
completion(.failure(error))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// No more recent stories
|
||||||
|
if case .success(let hasStories) = result, !hasStories {
|
||||||
|
completion(.success(()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
self.downloadFeed(account: account, feed: feed, page: page + 1, completion: completion)
|
||||||
|
}
|
||||||
|
|
||||||
|
case .failure(let error):
|
||||||
|
completion(.failure(error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func initialFeedDownload(account: Account, feed: WebFeed, completion: @escaping (Result<WebFeed, Error>) -> Void) {
|
||||||
|
refreshProgress.addToNumberOfTasksAndRemaining(1)
|
||||||
|
|
||||||
|
// Download the initial articles
|
||||||
|
downloadFeed(account: account, feed: feed, page: 1) { result in
|
||||||
|
self.refreshArticleStatus(for: account) { result in
|
||||||
|
switch result {
|
||||||
|
case .success:
|
||||||
|
self.refreshMissingStories(for: account) { result in
|
||||||
|
switch result {
|
||||||
|
case .success:
|
||||||
|
self.refreshProgress.completeTask()
|
||||||
|
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
completion(.success(feed))
|
||||||
|
}
|
||||||
|
|
||||||
|
case .failure(let error):
|
||||||
|
completion(.failure(error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case .failure(let error):
|
||||||
|
completion(.failure(error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteFeed(for account: Account, with feed: WebFeed, from container: Container?, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||||
|
// This error should never happen
|
||||||
|
guard let feedID = feed.externalID else {
|
||||||
|
completion(.failure(NewsBlurError.invalidParameter))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshProgress.addToNumberOfTasksAndRemaining(1)
|
||||||
|
|
||||||
|
let folderName = (container as? Folder)?.name
|
||||||
|
caller.deleteFeed(feedID: feedID, folder: folderName) { result in
|
||||||
|
self.refreshProgress.completeTask()
|
||||||
|
|
||||||
|
switch result {
|
||||||
|
case .success:
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
let feedID = feed.webFeedID
|
||||||
|
|
||||||
|
if folderName == nil {
|
||||||
|
account.removeWebFeed(feed)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let folders = account.folders {
|
||||||
|
for folder in folders where folderName != nil && folder.name == folderName {
|
||||||
|
folder.removeWebFeed(feed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if account.existingWebFeed(withWebFeedID: feedID) != nil {
|
||||||
|
account.clearWebFeedMetadata(feed)
|
||||||
|
}
|
||||||
|
|
||||||
|
completion(.success(()))
|
||||||
|
}
|
||||||
|
|
||||||
|
case .failure(let error):
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
let wrappedError = AccountError.wrappedError(error: error, account: account)
|
||||||
|
completion(.failure(wrappedError))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
95
Frameworks/Account/NewsBlur/Models/NewsBlurFeed.swift
Normal file
95
Frameworks/Account/NewsBlur/Models/NewsBlurFeed.swift
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
//
|
||||||
|
// NewsBlurFeed.swift
|
||||||
|
// Account
|
||||||
|
//
|
||||||
|
// Created by Anh Quang Do on 2020-03-09.
|
||||||
|
// Copyright (c) 2020 Ranchero Software, LLC. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import RSCore
|
||||||
|
import RSParser
|
||||||
|
|
||||||
|
typealias NewsBlurFolder = NewsBlurFeedsResponse.Folder
|
||||||
|
|
||||||
|
struct NewsBlurFeed: Hashable, Codable {
|
||||||
|
let name: String
|
||||||
|
let feedID: Int
|
||||||
|
let feedURL: String
|
||||||
|
let homePageURL: String?
|
||||||
|
let faviconURL: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct NewsBlurFeedsResponse: Decodable {
|
||||||
|
let feeds: [NewsBlurFeed]
|
||||||
|
let folders: [Folder]
|
||||||
|
|
||||||
|
struct Folder: Hashable, Codable {
|
||||||
|
let name: String
|
||||||
|
let feedIDs: [Int]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct NewsBlurAddURLResponse: Decodable {
|
||||||
|
let feed: NewsBlurFeed?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct NewsBlurFolderRelationship {
|
||||||
|
let folderName: String
|
||||||
|
let feedID: Int
|
||||||
|
}
|
||||||
|
|
||||||
|
extension NewsBlurFeed {
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case name = "feed_title"
|
||||||
|
case feedID = "id"
|
||||||
|
case feedURL = "feed_address"
|
||||||
|
case homePageURL = "feed_link"
|
||||||
|
case faviconURL = "favicon_url"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension NewsBlurFeedsResponse {
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case feeds = "feeds"
|
||||||
|
case folders = "flat_folders"
|
||||||
|
// TODO: flat_folders_with_inactive
|
||||||
|
}
|
||||||
|
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
|
||||||
|
// Tricky part: Some feeds listed in `feeds` don't exist in `folders` for some reason
|
||||||
|
// They don't show up on both mobile/web app, so let's filter them out
|
||||||
|
var visibleFeedIDs: [Int] = []
|
||||||
|
|
||||||
|
// Parse folders
|
||||||
|
var folders: [Folder] = []
|
||||||
|
let folderContainer = try container.nestedContainer(keyedBy: NewsBlurGenericCodingKeys.self, forKey: .folders)
|
||||||
|
|
||||||
|
for key in folderContainer.allKeys {
|
||||||
|
let feedIDs = try folderContainer.decode([Int].self, forKey: key)
|
||||||
|
let folder = Folder(name: key.stringValue, feedIDs: feedIDs)
|
||||||
|
|
||||||
|
folders.append(folder)
|
||||||
|
visibleFeedIDs.append(contentsOf: feedIDs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse feeds
|
||||||
|
var feeds: [NewsBlurFeed] = []
|
||||||
|
let feedContainer = try container.nestedContainer(keyedBy: NewsBlurGenericCodingKeys.self, forKey: .feeds)
|
||||||
|
try feedContainer.allKeys.forEach { key in
|
||||||
|
let feed = try feedContainer.decode(NewsBlurFeed.self, forKey: key)
|
||||||
|
feeds.append(feed)
|
||||||
|
}
|
||||||
|
|
||||||
|
self.feeds = feeds.filter { visibleFeedIDs.contains($0.feedID) }
|
||||||
|
self.folders = folders
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension NewsBlurFeedsResponse.Folder {
|
||||||
|
var asRelationships: [NewsBlurFolderRelationship] {
|
||||||
|
return feedIDs.map { NewsBlurFolderRelationship(folderName: name, feedID: $0) }
|
||||||
|
}
|
||||||
|
}
|
49
Frameworks/Account/NewsBlur/Models/NewsBlurFeedChange.swift
Normal file
49
Frameworks/Account/NewsBlur/Models/NewsBlurFeedChange.swift
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
//
|
||||||
|
// NewsBlurFeedChange.swift
|
||||||
|
// Account
|
||||||
|
//
|
||||||
|
// Created by Anh Quang Do on 2020-03-14.
|
||||||
|
// Copyright (c) 2020 Ranchero Software, LLC. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum NewsBlurFeedChange {
|
||||||
|
case add(String, String?)
|
||||||
|
case rename(String, String)
|
||||||
|
case delete(String, String?)
|
||||||
|
case move(String, String?, String?)
|
||||||
|
}
|
||||||
|
|
||||||
|
extension NewsBlurFeedChange: NewsBlurDataConvertible {
|
||||||
|
var asData: Data? {
|
||||||
|
var postData = URLComponents()
|
||||||
|
postData.queryItems = {
|
||||||
|
switch self {
|
||||||
|
case .add(let url, let folder):
|
||||||
|
return [
|
||||||
|
URLQueryItem(name: "url", value: url),
|
||||||
|
folder != nil ? URLQueryItem(name: "folder", value: folder) : nil
|
||||||
|
].compactMap { $0 }
|
||||||
|
case .rename(let feedID, let newName):
|
||||||
|
return [
|
||||||
|
URLQueryItem(name: "feed_id", value: feedID),
|
||||||
|
URLQueryItem(name: "feed_title", value: newName),
|
||||||
|
]
|
||||||
|
case .delete(let feedID, let folder):
|
||||||
|
return [
|
||||||
|
URLQueryItem(name: "feed_id", value: feedID),
|
||||||
|
folder != nil ? URLQueryItem(name: "in_folder", value: folder) : nil,
|
||||||
|
].compactMap { $0 }
|
||||||
|
case .move(let feedID, let from, let to):
|
||||||
|
return [
|
||||||
|
URLQueryItem(name: "feed_id", value: feedID),
|
||||||
|
URLQueryItem(name: "in_folder", value: from ?? ""),
|
||||||
|
URLQueryItem(name: "to_folder", value: to ?? ""),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return postData.percentEncodedQuery?.data(using: .utf8)
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,47 @@
|
|||||||
|
//
|
||||||
|
// NewsBlurFolderChange.swift
|
||||||
|
// Account
|
||||||
|
//
|
||||||
|
// Created by Anh Quang Do on 2020-03-14.
|
||||||
|
// Copyright (c) 2020 Ranchero Software, LLC. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum NewsBlurFolderChange {
|
||||||
|
case add(String)
|
||||||
|
case rename(String, String)
|
||||||
|
case delete(String, [String])
|
||||||
|
}
|
||||||
|
|
||||||
|
extension NewsBlurFolderChange: NewsBlurDataConvertible {
|
||||||
|
var asData: Data? {
|
||||||
|
var postData = URLComponents()
|
||||||
|
postData.queryItems = {
|
||||||
|
switch self {
|
||||||
|
case .add(let name):
|
||||||
|
return [
|
||||||
|
URLQueryItem(name: "folder", value: name),
|
||||||
|
URLQueryItem(name: "parent_folder", value: ""), // root folder
|
||||||
|
]
|
||||||
|
case .rename(let from, let to):
|
||||||
|
return [
|
||||||
|
URLQueryItem(name: "folder_to_rename", value: from),
|
||||||
|
URLQueryItem(name: "new_folder_name", value: to),
|
||||||
|
URLQueryItem(name: "in_folder", value: ""), // root folder
|
||||||
|
]
|
||||||
|
case .delete(let name, let feedIDs):
|
||||||
|
var queryItems = [
|
||||||
|
URLQueryItem(name: "folder_to_delete", value: name),
|
||||||
|
URLQueryItem(name: "in_folder", value: ""), // root folder
|
||||||
|
]
|
||||||
|
queryItems.append(contentsOf: feedIDs.map { id in
|
||||||
|
URLQueryItem(name: "feed_id", value: id)
|
||||||
|
})
|
||||||
|
return queryItems
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return postData.percentEncodedQuery?.data(using: .utf8)
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,25 @@
|
|||||||
|
//
|
||||||
|
// NewsBlurGenericCodingKeys.swift
|
||||||
|
// Account
|
||||||
|
//
|
||||||
|
// Created by Anh Quang Do on 2020-03-10.
|
||||||
|
// Copyright (c) 2020 Ranchero Software, LLC. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct NewsBlurGenericCodingKeys: CodingKey {
|
||||||
|
var stringValue: String
|
||||||
|
|
||||||
|
init?(stringValue: String) {
|
||||||
|
self.stringValue = stringValue
|
||||||
|
}
|
||||||
|
|
||||||
|
var intValue: Int? {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
init?(intValue: Int) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,26 @@
|
|||||||
|
//
|
||||||
|
// NewsBlurLoginResponse.swift
|
||||||
|
// Account
|
||||||
|
//
|
||||||
|
// Created by Anh Quang Do on 2020-03-09.
|
||||||
|
// Copyright (c) 2020 Ranchero Software, LLC. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct NewsBlurLoginResponse: Decodable {
|
||||||
|
var code: Int
|
||||||
|
var errors: LoginError?
|
||||||
|
|
||||||
|
struct LoginError: Decodable {
|
||||||
|
var username: [String]?
|
||||||
|
var others: [String]?
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension NewsBlurLoginResponse.LoginError {
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case username = "username"
|
||||||
|
case others = "__all__"
|
||||||
|
}
|
||||||
|
}
|
57
Frameworks/Account/NewsBlur/Models/NewsBlurStory.swift
Normal file
57
Frameworks/Account/NewsBlur/Models/NewsBlurStory.swift
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
//
|
||||||
|
// NewsBlurStory.swift
|
||||||
|
// Account
|
||||||
|
//
|
||||||
|
// Created by Anh Quang Do on 2020-03-10.
|
||||||
|
// Copyright (c) 2020 Ranchero Software, LLC. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import RSCore
|
||||||
|
import RSParser
|
||||||
|
|
||||||
|
typealias NewsBlurStory = NewsBlurStoriesResponse.Story
|
||||||
|
|
||||||
|
struct NewsBlurStoriesResponse: Decodable {
|
||||||
|
let stories: [Story]
|
||||||
|
|
||||||
|
struct Story: Decodable {
|
||||||
|
let storyID: String
|
||||||
|
let feedID: Int
|
||||||
|
let title: String?
|
||||||
|
let url: String?
|
||||||
|
let authorName: String?
|
||||||
|
let contentHTML: String?
|
||||||
|
var imageURL: String? {
|
||||||
|
return imageURLs?.first?.value
|
||||||
|
}
|
||||||
|
var tags: [String]?
|
||||||
|
var datePublished: Date? {
|
||||||
|
let interval = (publishedTimestamp as NSString).doubleValue
|
||||||
|
return Date(timeIntervalSince1970: interval)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var imageURLs: [String: String]?
|
||||||
|
private var publishedTimestamp: String
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension NewsBlurStoriesResponse {
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case stories = "stories"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension NewsBlurStoriesResponse.Story {
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case storyID = "story_hash"
|
||||||
|
case feedID = "story_feed_id"
|
||||||
|
case title = "story_title"
|
||||||
|
case url = "story_permalink"
|
||||||
|
case authorName = "story_authors"
|
||||||
|
case contentHTML = "story_content"
|
||||||
|
case imageURLs = "secure_image_urls"
|
||||||
|
case tags = "story_tags"
|
||||||
|
case publishedTimestamp = "story_timestamp"
|
||||||
|
}
|
||||||
|
}
|
80
Frameworks/Account/NewsBlur/Models/NewsBlurStoryHash.swift
Normal file
80
Frameworks/Account/NewsBlur/Models/NewsBlurStoryHash.swift
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
//
|
||||||
|
// NewsBlurStoryHash.swift
|
||||||
|
// Account
|
||||||
|
//
|
||||||
|
// Created by Anh Quang Do on 2020-03-13.
|
||||||
|
// Copyright (c) 2020 Ranchero Software, LLC. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import RSCore
|
||||||
|
import RSParser
|
||||||
|
|
||||||
|
typealias NewsBlurStoryHash = NewsBlurStoryHashesResponse.StoryHash
|
||||||
|
|
||||||
|
struct NewsBlurStoryHashesResponse: Decodable {
|
||||||
|
typealias StoryHashDictionary = [String: [StoryHash]]
|
||||||
|
|
||||||
|
var unread: [StoryHash]?
|
||||||
|
var starred: [StoryHash]?
|
||||||
|
|
||||||
|
struct StoryHash: Hashable, Codable {
|
||||||
|
var hash: String
|
||||||
|
var timestamp: Date
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension NewsBlurStoryHashesResponse {
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case unread = "unread_feed_story_hashes"
|
||||||
|
case starred = "starred_story_hashes"
|
||||||
|
}
|
||||||
|
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
|
||||||
|
// Parse unread
|
||||||
|
if let unreadContainer = try? container.nestedContainer(keyedBy: NewsBlurGenericCodingKeys.self, forKey: .unread) {
|
||||||
|
let storyHashes = try NewsBlurStoryHashesResponse.extractHashes(container: unreadContainer)
|
||||||
|
self.unread = storyHashes.values.flatMap { $0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse starred
|
||||||
|
if let starredContainer = try? container.nestedUnkeyedContainer(forKey: .starred) {
|
||||||
|
self.starred = try NewsBlurStoryHashesResponse.extractArray(container: starredContainer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func extractHashes<Key>(container: KeyedDecodingContainer<Key>) throws -> StoryHashDictionary where Key: CodingKey {
|
||||||
|
var dict: StoryHashDictionary = [:]
|
||||||
|
for key in container.allKeys {
|
||||||
|
dict[key.stringValue] = []
|
||||||
|
var hashArrayContainer = try container.nestedUnkeyedContainer(forKey: key)
|
||||||
|
while !hashArrayContainer.isAtEnd {
|
||||||
|
var hashContainer = try hashArrayContainer.nestedUnkeyedContainer()
|
||||||
|
let hash = try hashContainer.decode(String.self)
|
||||||
|
let timestamp = try hashContainer.decode(Date.self)
|
||||||
|
let storyHash = StoryHash(hash: hash, timestamp: timestamp)
|
||||||
|
|
||||||
|
dict[key.stringValue]?.append(storyHash)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return dict
|
||||||
|
}
|
||||||
|
|
||||||
|
static func extractArray(container: UnkeyedDecodingContainer) throws -> [StoryHash] {
|
||||||
|
var hashes: [StoryHash] = []
|
||||||
|
var hashArrayContainer = container
|
||||||
|
while !hashArrayContainer.isAtEnd {
|
||||||
|
var hashContainer = try hashArrayContainer.nestedUnkeyedContainer()
|
||||||
|
let hash = try hashContainer.decode(String.self)
|
||||||
|
let timestamp = try (hashContainer.decode(String.self) as NSString).doubleValue
|
||||||
|
let storyHash = StoryHash(hash: hash, timestamp: Date(timeIntervalSince1970: timestamp))
|
||||||
|
|
||||||
|
hashes.append(storyHash)
|
||||||
|
}
|
||||||
|
|
||||||
|
return hashes
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,22 @@
|
|||||||
|
//
|
||||||
|
// NewsBlurStoryStatusChange.swift
|
||||||
|
// Account
|
||||||
|
//
|
||||||
|
// Created by Anh Quang Do on 2020-03-13.
|
||||||
|
// Copyright (c) 2020 Ranchero Software, LLC. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct NewsBlurStoryStatusChange {
|
||||||
|
let hashes: [String]
|
||||||
|
}
|
||||||
|
|
||||||
|
extension NewsBlurStoryStatusChange: NewsBlurDataConvertible {
|
||||||
|
var asData: Data? {
|
||||||
|
var postData = URLComponents()
|
||||||
|
postData.queryItems = hashes.map { URLQueryItem(name: "story_hash", value: $0) }
|
||||||
|
|
||||||
|
return postData.percentEncodedQuery?.data(using: .utf8)
|
||||||
|
}
|
||||||
|
}
|
279
Frameworks/Account/NewsBlur/NewsBlurAPICaller.swift
Normal file
279
Frameworks/Account/NewsBlur/NewsBlurAPICaller.swift
Normal file
@ -0,0 +1,279 @@
|
|||||||
|
//
|
||||||
|
// NewsBlurAPICaller.swift
|
||||||
|
// Account
|
||||||
|
//
|
||||||
|
// Created by Anh-Quang Do on 3/9/20.
|
||||||
|
// Copyright (c) 2020 Ranchero Software, LLC. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import RSWeb
|
||||||
|
|
||||||
|
final class NewsBlurAPICaller: NSObject {
|
||||||
|
static let SessionIdCookie = "newsblur_sessionid"
|
||||||
|
|
||||||
|
let baseURL = URL(string: "https://www.newsblur.com/")!
|
||||||
|
var transport: Transport!
|
||||||
|
var suspended = false
|
||||||
|
|
||||||
|
var credentials: Credentials?
|
||||||
|
weak var accountMetadata: AccountMetadata?
|
||||||
|
|
||||||
|
init(transport: Transport!) {
|
||||||
|
super.init()
|
||||||
|
self.transport = transport
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cancels all pending requests rejects any that come in later
|
||||||
|
func suspend() {
|
||||||
|
transport.cancelAll()
|
||||||
|
suspended = true
|
||||||
|
}
|
||||||
|
|
||||||
|
func resume() {
|
||||||
|
suspended = false
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateCredentials(completion: @escaping (Result<Credentials?, Error>) -> Void) {
|
||||||
|
requestData(endpoint: "api/login", resultType: NewsBlurLoginResponse.self) { result in
|
||||||
|
switch result {
|
||||||
|
case .success(let response, let payload):
|
||||||
|
guard let url = response.url, let headerFields = response.allHeaderFields as? [String: String], payload?.code != -1 else {
|
||||||
|
let error = payload?.errors?.username ?? payload?.errors?.others
|
||||||
|
if let message = error?.first {
|
||||||
|
completion(.failure(NewsBlurError.general(message: message)))
|
||||||
|
} else {
|
||||||
|
completion(.failure(NewsBlurError.unknown))
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let username = self.credentials?.username else {
|
||||||
|
completion(.failure(NewsBlurError.unknown))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let cookies = HTTPCookie.cookies(withResponseHeaderFields: headerFields, for: url)
|
||||||
|
for cookie in cookies where cookie.name == Self.SessionIdCookie {
|
||||||
|
let credentials = Credentials(type: .newsBlurSessionId, username: username, secret: cookie.value)
|
||||||
|
completion(.success(credentials))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
completion(.failure(NewsBlurError.general(message: "Failed to retrieve session")))
|
||||||
|
case .failure(let error):
|
||||||
|
completion(.failure(error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func logout(completion: @escaping (Result<Void, Error>) -> Void) {
|
||||||
|
requestData(endpoint: "api/logout", completion: completion)
|
||||||
|
}
|
||||||
|
|
||||||
|
func retrieveFeeds(completion: @escaping (Result<([NewsBlurFeed]?, [NewsBlurFolder]?), Error>) -> Void) {
|
||||||
|
let url = baseURL
|
||||||
|
.appendingPathComponent("reader/feeds")
|
||||||
|
.appendingQueryItems([
|
||||||
|
URLQueryItem(name: "flat", value: "true"),
|
||||||
|
URLQueryItem(name: "update_counts", value: "false"),
|
||||||
|
])
|
||||||
|
|
||||||
|
requestData(callURL: url, resultType: NewsBlurFeedsResponse.self) { result in
|
||||||
|
switch result {
|
||||||
|
case .success((_, let payload)):
|
||||||
|
completion(.success((payload?.feeds, payload?.folders)))
|
||||||
|
case .failure(let error):
|
||||||
|
completion(.failure(error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func retrieveStoryHashes(endpoint: String, completion: @escaping (Result<[NewsBlurStoryHash]?, Error>) -> Void) {
|
||||||
|
let url = baseURL
|
||||||
|
.appendingPathComponent(endpoint)
|
||||||
|
.appendingQueryItems([
|
||||||
|
URLQueryItem(name: "include_timestamps", value: "true"),
|
||||||
|
])
|
||||||
|
|
||||||
|
requestData(
|
||||||
|
callURL: url,
|
||||||
|
resultType: NewsBlurStoryHashesResponse.self,
|
||||||
|
dateDecoding: .secondsSince1970
|
||||||
|
) { result in
|
||||||
|
switch result {
|
||||||
|
case .success((_, let payload)):
|
||||||
|
let hashes = payload?.unread ?? payload?.starred
|
||||||
|
completion(.success(hashes))
|
||||||
|
case .failure(let error):
|
||||||
|
completion(.failure(error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func retrieveUnreadStoryHashes(completion: @escaping (Result<[NewsBlurStoryHash]?, Error>) -> Void) {
|
||||||
|
retrieveStoryHashes(
|
||||||
|
endpoint: "reader/unread_story_hashes",
|
||||||
|
completion: completion
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func retrieveStarredStoryHashes(completion: @escaping (Result<[NewsBlurStoryHash]?, Error>) -> Void) {
|
||||||
|
retrieveStoryHashes(
|
||||||
|
endpoint: "reader/starred_story_hashes",
|
||||||
|
completion: completion
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func retrieveStories(feedID: String, page: Int, completion: @escaping (Result<([NewsBlurStory]?, Date?), Error>) -> Void) {
|
||||||
|
let url = baseURL
|
||||||
|
.appendingPathComponent("reader/feed/\(feedID)")
|
||||||
|
.appendingQueryItems([
|
||||||
|
URLQueryItem(name: "page", value: String(page)),
|
||||||
|
URLQueryItem(name: "order", value: "newest"),
|
||||||
|
URLQueryItem(name: "read_filter", value: "all"),
|
||||||
|
URLQueryItem(name: "include_hidden", value: "true"),
|
||||||
|
URLQueryItem(name: "include_story_content", value: "true"),
|
||||||
|
])
|
||||||
|
|
||||||
|
requestData(callURL: url, resultType: NewsBlurStoriesResponse.self) { result in
|
||||||
|
switch result {
|
||||||
|
case .success(let (response, payload)):
|
||||||
|
completion(.success((payload?.stories, HTTPDateInfo(urlResponse: response)?.date)))
|
||||||
|
case .failure(let error):
|
||||||
|
completion(.failure(error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func retrieveStories(hashes: [NewsBlurStoryHash], completion: @escaping (Result<([NewsBlurStory]?, Date?), Error>) -> Void) {
|
||||||
|
let url = baseURL
|
||||||
|
.appendingPathComponent("reader/river_stories")
|
||||||
|
.appendingQueryItem(.init(name: "include_hidden", value: "true"))?
|
||||||
|
.appendingQueryItems(hashes.map {
|
||||||
|
URLQueryItem(name: "h", value: $0.hash)
|
||||||
|
})
|
||||||
|
|
||||||
|
requestData(callURL: url, resultType: NewsBlurStoriesResponse.self) { result in
|
||||||
|
switch result {
|
||||||
|
case .success(let (response, payload)):
|
||||||
|
completion(.success((payload?.stories, HTTPDateInfo(urlResponse: response)?.date)))
|
||||||
|
case .failure(let error):
|
||||||
|
completion(.failure(error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func markAsUnread(hashes: [String], completion: @escaping (Result<Void, Error>) -> Void) {
|
||||||
|
sendUpdates(
|
||||||
|
endpoint: "reader/mark_story_hash_as_unread",
|
||||||
|
payload: NewsBlurStoryStatusChange(hashes: hashes),
|
||||||
|
completion: completion
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func markAsRead(hashes: [String], completion: @escaping (Result<Void, Error>) -> Void) {
|
||||||
|
sendUpdates(
|
||||||
|
endpoint: "reader/mark_story_hashes_as_read",
|
||||||
|
payload: NewsBlurStoryStatusChange(hashes: hashes),
|
||||||
|
completion: completion
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func star(hashes: [String], completion: @escaping (Result<Void, Error>) -> Void) {
|
||||||
|
sendUpdates(
|
||||||
|
endpoint: "reader/mark_story_hash_as_starred",
|
||||||
|
payload: NewsBlurStoryStatusChange(hashes: hashes),
|
||||||
|
completion: completion
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func unstar(hashes: [String], completion: @escaping (Result<Void, Error>) -> Void) {
|
||||||
|
sendUpdates(
|
||||||
|
endpoint: "reader/mark_story_hash_as_unstarred",
|
||||||
|
payload: NewsBlurStoryStatusChange(hashes: hashes),
|
||||||
|
completion: completion
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func addFolder(named name: String, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||||
|
sendUpdates(
|
||||||
|
endpoint: "reader/add_folder",
|
||||||
|
payload: NewsBlurFolderChange.add(name),
|
||||||
|
completion: completion
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func renameFolder(with folder: String, to name: String, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||||
|
sendUpdates(
|
||||||
|
endpoint: "reader/rename_folder",
|
||||||
|
payload: NewsBlurFolderChange.rename(folder, name),
|
||||||
|
completion: completion
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeFolder(named name: String, feedIDs: [String], completion: @escaping (Result<Void, Error>) -> Void) {
|
||||||
|
sendUpdates(
|
||||||
|
endpoint: "reader/delete_folder",
|
||||||
|
payload: NewsBlurFolderChange.delete(name, feedIDs),
|
||||||
|
completion: completion
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func addURL(_ url: String, folder: String?, completion: @escaping (Result<NewsBlurFeed?, Error>) -> Void) {
|
||||||
|
sendUpdates(
|
||||||
|
endpoint: "reader/add_url",
|
||||||
|
payload: NewsBlurFeedChange.add(url, folder),
|
||||||
|
resultType: NewsBlurAddURLResponse.self
|
||||||
|
) { result in
|
||||||
|
switch result {
|
||||||
|
case .success(_, let payload):
|
||||||
|
completion(.success(payload?.feed))
|
||||||
|
case .failure(let error):
|
||||||
|
completion(.failure(error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func renameFeed(feedID: String, newName: String, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||||
|
sendUpdates(
|
||||||
|
endpoint: "reader/rename_feed",
|
||||||
|
payload: NewsBlurFeedChange.rename(feedID, newName)
|
||||||
|
) { result in
|
||||||
|
switch result {
|
||||||
|
case .success:
|
||||||
|
completion(.success(()))
|
||||||
|
case .failure(let error):
|
||||||
|
completion(.failure(error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteFeed(feedID: String, folder: String? = nil, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||||
|
sendUpdates(
|
||||||
|
endpoint: "reader/delete_feed",
|
||||||
|
payload: NewsBlurFeedChange.delete(feedID, folder)
|
||||||
|
) { result in
|
||||||
|
switch result {
|
||||||
|
case .success:
|
||||||
|
completion(.success(()))
|
||||||
|
case .failure(let error):
|
||||||
|
completion(.failure(error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func moveFeed(feedID: String, from: String?, to: String?, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||||
|
sendUpdates(
|
||||||
|
endpoint: "reader/move_feed_to_folder",
|
||||||
|
payload: NewsBlurFeedChange.move(feedID, from, to)
|
||||||
|
) { result in
|
||||||
|
switch result {
|
||||||
|
case .success:
|
||||||
|
completion(.success(()))
|
||||||
|
case .failure(let error):
|
||||||
|
completion(.failure(error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
621
Frameworks/Account/NewsBlur/NewsBlurAccountDelegate.swift
Normal file
621
Frameworks/Account/NewsBlur/NewsBlurAccountDelegate.swift
Normal file
@ -0,0 +1,621 @@
|
|||||||
|
//
|
||||||
|
// NewsBlurAccountDelegate.swift
|
||||||
|
// Account
|
||||||
|
//
|
||||||
|
// Created by Anh-Quang Do on 3/9/20.
|
||||||
|
// Copyright (c) 2020 Ranchero Software, LLC. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Articles
|
||||||
|
import RSCore
|
||||||
|
import RSDatabase
|
||||||
|
import RSParser
|
||||||
|
import RSWeb
|
||||||
|
import SyncDatabase
|
||||||
|
import os.log
|
||||||
|
|
||||||
|
final class NewsBlurAccountDelegate: AccountDelegate {
|
||||||
|
|
||||||
|
var behaviors: AccountBehaviors = []
|
||||||
|
|
||||||
|
var isOPMLImportInProgress: Bool = false
|
||||||
|
var server: String? = "newsblur.com"
|
||||||
|
var credentials: Credentials? {
|
||||||
|
didSet {
|
||||||
|
caller.credentials = credentials
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var accountMetadata: AccountMetadata? = nil
|
||||||
|
var refreshProgress = DownloadProgress(numberOfTasks: 0)
|
||||||
|
|
||||||
|
let caller: NewsBlurAPICaller
|
||||||
|
let log = OSLog(subsystem: Bundle.main.bundleIdentifier!, category: "NewsBlur")
|
||||||
|
let database: SyncDatabase
|
||||||
|
|
||||||
|
init(dataFolder: String, transport: Transport?) {
|
||||||
|
if let transport = transport {
|
||||||
|
caller = NewsBlurAPICaller(transport: transport)
|
||||||
|
} else {
|
||||||
|
let sessionConfiguration = URLSessionConfiguration.default
|
||||||
|
sessionConfiguration.requestCachePolicy = .reloadIgnoringLocalCacheData
|
||||||
|
sessionConfiguration.timeoutIntervalForRequest = 60.0
|
||||||
|
sessionConfiguration.httpShouldSetCookies = false
|
||||||
|
sessionConfiguration.httpCookieAcceptPolicy = .never
|
||||||
|
sessionConfiguration.httpMaximumConnectionsPerHost = 1
|
||||||
|
sessionConfiguration.httpCookieStorage = nil
|
||||||
|
sessionConfiguration.urlCache = nil
|
||||||
|
|
||||||
|
if let userAgentHeaders = UserAgent.headers() {
|
||||||
|
sessionConfiguration.httpAdditionalHeaders = userAgentHeaders
|
||||||
|
}
|
||||||
|
|
||||||
|
let session = URLSession(configuration: sessionConfiguration)
|
||||||
|
caller = NewsBlurAPICaller(transport: session)
|
||||||
|
}
|
||||||
|
|
||||||
|
database = SyncDatabase(databaseFilePath: dataFolder.appending("/DB.sqlite3"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func refreshAll(for account: Account, completion: @escaping (Result<Void, Error>) -> ()) {
|
||||||
|
self.refreshProgress.addToNumberOfTasksAndRemaining(5)
|
||||||
|
|
||||||
|
refreshFeeds(for: account) { result in
|
||||||
|
self.refreshProgress.completeTask()
|
||||||
|
|
||||||
|
switch result {
|
||||||
|
case .success:
|
||||||
|
self.sendArticleStatus(for: account) { result in
|
||||||
|
self.refreshProgress.completeTask()
|
||||||
|
|
||||||
|
switch result {
|
||||||
|
case .success:
|
||||||
|
self.refreshArticleStatus(for: account) { result in
|
||||||
|
self.refreshProgress.completeTask()
|
||||||
|
|
||||||
|
switch result {
|
||||||
|
case .success:
|
||||||
|
self.refreshStories(for: account) { result in
|
||||||
|
self.refreshProgress.completeTask()
|
||||||
|
|
||||||
|
switch result {
|
||||||
|
case .success:
|
||||||
|
self.refreshMissingStories(for: account) { result in
|
||||||
|
self.refreshProgress.completeTask()
|
||||||
|
|
||||||
|
switch result {
|
||||||
|
case .success:
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
completion(.success(()))
|
||||||
|
}
|
||||||
|
|
||||||
|
case .failure(let error):
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.refreshProgress.clear()
|
||||||
|
let wrappedError = AccountError.wrappedError(error: error, account: account)
|
||||||
|
completion(.failure(wrappedError))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case .failure(let error):
|
||||||
|
completion(.failure(error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case .failure(let error):
|
||||||
|
completion(.failure(error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case .failure(let error):
|
||||||
|
completion(.failure(error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case .failure(let error):
|
||||||
|
completion(.failure(error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendArticleStatus(for account: Account, completion: @escaping (Result<Void, Error>) -> ()) {
|
||||||
|
os_log(.debug, log: log, "Sending story statuses...")
|
||||||
|
|
||||||
|
database.selectForProcessing { result in
|
||||||
|
|
||||||
|
func processStatuses(_ syncStatuses: [SyncStatus]) {
|
||||||
|
let createUnreadStatuses = syncStatuses.filter {
|
||||||
|
$0.key == ArticleStatus.Key.read && $0.flag == false
|
||||||
|
}
|
||||||
|
let deleteUnreadStatuses = syncStatuses.filter {
|
||||||
|
$0.key == ArticleStatus.Key.read && $0.flag == true
|
||||||
|
}
|
||||||
|
let createStarredStatuses = syncStatuses.filter {
|
||||||
|
$0.key == ArticleStatus.Key.starred && $0.flag == true
|
||||||
|
}
|
||||||
|
let deleteStarredStatuses = syncStatuses.filter {
|
||||||
|
$0.key == ArticleStatus.Key.starred && $0.flag == false
|
||||||
|
}
|
||||||
|
|
||||||
|
let group = DispatchGroup()
|
||||||
|
var errorOccurred = false
|
||||||
|
|
||||||
|
group.enter()
|
||||||
|
self.sendStoryStatuses(createUnreadStatuses, throttle: true, apiCall: self.caller.markAsUnread) { result in
|
||||||
|
group.leave()
|
||||||
|
if case .failure = result {
|
||||||
|
errorOccurred = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
group.enter()
|
||||||
|
self.sendStoryStatuses(deleteUnreadStatuses, throttle: false, apiCall: self.caller.markAsRead) { result in
|
||||||
|
group.leave()
|
||||||
|
if case .failure = result {
|
||||||
|
errorOccurred = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
group.enter()
|
||||||
|
self.sendStoryStatuses(createStarredStatuses, throttle: true, apiCall: self.caller.star) { result in
|
||||||
|
group.leave()
|
||||||
|
if case .failure = result {
|
||||||
|
errorOccurred = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
group.enter()
|
||||||
|
self.sendStoryStatuses(deleteStarredStatuses, throttle: true, apiCall: self.caller.unstar) { result in
|
||||||
|
group.leave()
|
||||||
|
if case .failure = result {
|
||||||
|
errorOccurred = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
group.notify(queue: DispatchQueue.main) {
|
||||||
|
os_log(.debug, log: self.log, "Done sending article statuses.")
|
||||||
|
if errorOccurred {
|
||||||
|
completion(.failure(NewsBlurError.unknown))
|
||||||
|
} else {
|
||||||
|
completion(.success(()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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>) -> ()) {
|
||||||
|
os_log(.debug, log: log, "Refreshing story statuses...")
|
||||||
|
|
||||||
|
let group = DispatchGroup()
|
||||||
|
var errorOccurred = false
|
||||||
|
|
||||||
|
group.enter()
|
||||||
|
caller.retrieveUnreadStoryHashes { result in
|
||||||
|
switch result {
|
||||||
|
case .success(let storyHashes):
|
||||||
|
self.syncStoryReadState(account: account, hashes: storyHashes)
|
||||||
|
group.leave()
|
||||||
|
case .failure(let error):
|
||||||
|
errorOccurred = true
|
||||||
|
os_log(.info, log: self.log, "Retrieving unread stories failed: %@.", error.localizedDescription)
|
||||||
|
group.leave()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
group.enter()
|
||||||
|
caller.retrieveStarredStoryHashes { result in
|
||||||
|
switch result {
|
||||||
|
case .success(let storyHashes):
|
||||||
|
self.syncStoryStarredState(account: account, hashes: storyHashes)
|
||||||
|
group.leave()
|
||||||
|
case .failure(let error):
|
||||||
|
errorOccurred = true
|
||||||
|
os_log(.info, log: self.log, "Retrieving starred stories failed: %@.", error.localizedDescription)
|
||||||
|
group.leave()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
group.notify(queue: DispatchQueue.main) {
|
||||||
|
os_log(.debug, log: self.log, "Done refreshing article statuses.")
|
||||||
|
if errorOccurred {
|
||||||
|
completion(.failure(NewsBlurError.unknown))
|
||||||
|
} else {
|
||||||
|
completion(.success(()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func refreshStories(for account: Account, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||||
|
os_log(.debug, log: log, "Refreshing stories...")
|
||||||
|
os_log(.debug, log: log, "Refreshing unread stories...")
|
||||||
|
|
||||||
|
caller.retrieveUnreadStoryHashes { result in
|
||||||
|
switch result {
|
||||||
|
case .success(let storyHashes):
|
||||||
|
self.refreshProgress.completeTask()
|
||||||
|
|
||||||
|
if let count = storyHashes?.count, count > 0 {
|
||||||
|
self.refreshProgress.addToNumberOfTasksAndRemaining((count - 1) / 100 + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
self.refreshUnreadStories(for: account, hashes: storyHashes, updateFetchDate: nil, completion: completion)
|
||||||
|
case .failure(let error):
|
||||||
|
completion(.failure(error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func refreshMissingStories(for account: Account, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||||
|
os_log(.debug, log: log, "Refreshing missing stories...")
|
||||||
|
|
||||||
|
account.fetchArticleIDsForStatusesWithoutArticlesNewerThanCutoffDate { result in
|
||||||
|
|
||||||
|
func process(_ fetchedHashes: Set<String>) {
|
||||||
|
let group = DispatchGroup()
|
||||||
|
var errorOccurred = false
|
||||||
|
|
||||||
|
let storyHashes = Array(fetchedHashes).map {
|
||||||
|
NewsBlurStoryHash(hash: $0, timestamp: Date())
|
||||||
|
}
|
||||||
|
let chunkedStoryHashes = storyHashes.chunked(into: 100)
|
||||||
|
|
||||||
|
for chunk in chunkedStoryHashes {
|
||||||
|
group.enter()
|
||||||
|
self.caller.retrieveStories(hashes: chunk) { result in
|
||||||
|
|
||||||
|
switch result {
|
||||||
|
case .success((let stories, _)):
|
||||||
|
self.processStories(account: account, stories: stories) { result in
|
||||||
|
group.leave()
|
||||||
|
if case .failure = result {
|
||||||
|
errorOccurred = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case .failure(let error):
|
||||||
|
errorOccurred = true
|
||||||
|
os_log(.error, log: self.log, "Refresh missing stories failed: %@.", error.localizedDescription)
|
||||||
|
group.leave()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
group.notify(queue: DispatchQueue.main) {
|
||||||
|
self.refreshProgress.completeTask()
|
||||||
|
os_log(.debug, log: self.log, "Done refreshing missing stories.")
|
||||||
|
if errorOccurred {
|
||||||
|
completion(.failure(NewsBlurError.unknown))
|
||||||
|
} else {
|
||||||
|
completion(.success(()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch result {
|
||||||
|
case .success(let fetchedArticleIDs):
|
||||||
|
process(fetchedArticleIDs)
|
||||||
|
case .failure(let error):
|
||||||
|
self.refreshProgress.completeTask()
|
||||||
|
completion(.failure(error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func processStories(account: Account, stories: [NewsBlurStory]?, since: Date? = nil, completion: @escaping (Result<Bool, DatabaseError>) -> Void) {
|
||||||
|
let parsedItems = mapStoriesToParsedItems(stories: stories).filter {
|
||||||
|
guard let datePublished = $0.datePublished, let since = since else {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return datePublished >= since
|
||||||
|
}
|
||||||
|
let webFeedIDsAndItems = Dictionary(grouping: parsedItems, by: { item in item.feedURL }).mapValues {
|
||||||
|
Set($0)
|
||||||
|
}
|
||||||
|
|
||||||
|
account.update(webFeedIDsAndItems: webFeedIDsAndItems, defaultRead: true) { error in
|
||||||
|
if let error = error {
|
||||||
|
completion(.failure(error))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
completion(.success(!webFeedIDsAndItems.isEmpty))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func importOPML(for account: Account, opmlFile: URL, completion: @escaping (Result<Void, Error>) -> ()) {
|
||||||
|
completion(.success(()))
|
||||||
|
}
|
||||||
|
|
||||||
|
func addFolder(for account: Account, name: String, completion: @escaping (Result<Folder, Error>) -> ()) {
|
||||||
|
self.refreshProgress.addToNumberOfTasksAndRemaining(1)
|
||||||
|
|
||||||
|
caller.addFolder(named: name) { result in
|
||||||
|
self.refreshProgress.completeTask()
|
||||||
|
|
||||||
|
switch result {
|
||||||
|
case .success():
|
||||||
|
if let folder = account.ensureFolder(with: name) {
|
||||||
|
completion(.success(folder))
|
||||||
|
} else {
|
||||||
|
completion(.failure(NewsBlurError.invalidParameter))
|
||||||
|
}
|
||||||
|
case .failure(let error):
|
||||||
|
completion(.failure(error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func renameFolder(for account: Account, with folder: Folder, to name: String, completion: @escaping (Result<Void, Error>) -> ()) {
|
||||||
|
guard let folderToRename = folder.name else {
|
||||||
|
completion(.failure(NewsBlurError.invalidParameter))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshProgress.addToNumberOfTasksAndRemaining(1)
|
||||||
|
|
||||||
|
let nameBefore = folder.name
|
||||||
|
|
||||||
|
caller.renameFolder(with: folderToRename, to: name) { result in
|
||||||
|
self.refreshProgress.completeTask()
|
||||||
|
|
||||||
|
switch result {
|
||||||
|
case .success:
|
||||||
|
completion(.success(()))
|
||||||
|
case .failure(let error):
|
||||||
|
folder.name = nameBefore
|
||||||
|
completion(.failure(error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
folder.name = name
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeFolder(for account: Account, with folder: Folder, completion: @escaping (Result<Void, Error>) -> ()) {
|
||||||
|
guard let folderToRemove = folder.name else {
|
||||||
|
completion(.failure(NewsBlurError.invalidParameter))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var feedIDs: [String] = []
|
||||||
|
for feed in folder.topLevelWebFeeds {
|
||||||
|
if (feed.folderRelationship?.count ?? 0) > 1 {
|
||||||
|
clearFolderRelationship(for: feed, withFolderName: folderToRemove)
|
||||||
|
} else if let feedID = feed.externalID {
|
||||||
|
feedIDs.append(feedID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshProgress.addToNumberOfTasksAndRemaining(1)
|
||||||
|
|
||||||
|
caller.removeFolder(named: folderToRemove, feedIDs: feedIDs) { result in
|
||||||
|
self.refreshProgress.completeTask()
|
||||||
|
|
||||||
|
switch result {
|
||||||
|
case .success:
|
||||||
|
account.removeFolder(folder)
|
||||||
|
completion(.success(()))
|
||||||
|
case .failure(let error):
|
||||||
|
completion(.failure(error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func createWebFeed(for account: Account, url: String, name: String?, container: Container, completion: @escaping (Result<WebFeed, Error>) -> ()) {
|
||||||
|
refreshProgress.addToNumberOfTasksAndRemaining(1)
|
||||||
|
|
||||||
|
let folderName = (container as? Folder)?.name
|
||||||
|
caller.addURL(url, folder: folderName) { result in
|
||||||
|
self.refreshProgress.completeTask()
|
||||||
|
|
||||||
|
switch result {
|
||||||
|
case .success(let feed):
|
||||||
|
self.createFeed(account: account, feed: feed, name: name, container: container, completion: completion)
|
||||||
|
case .failure(let error):
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
let wrappedError = AccountError.wrappedError(error: error, account: account)
|
||||||
|
completion(.failure(wrappedError))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func renameWebFeed(for account: Account, with feed: WebFeed, to name: String, completion: @escaping (Result<Void, Error>) -> ()) {
|
||||||
|
guard let feedID = feed.externalID else {
|
||||||
|
completion(.failure(NewsBlurError.invalidParameter))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshProgress.addToNumberOfTasksAndRemaining(1)
|
||||||
|
|
||||||
|
caller.renameFeed(feedID: feedID, newName: name) { result in
|
||||||
|
self.refreshProgress.completeTask()
|
||||||
|
|
||||||
|
switch result {
|
||||||
|
case .success:
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
feed.editedName = name
|
||||||
|
completion(.success(()))
|
||||||
|
}
|
||||||
|
|
||||||
|
case .failure(let error):
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
let wrappedError = AccountError.wrappedError(error: error, account: account)
|
||||||
|
completion(.failure(wrappedError))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func addWebFeed(for account: Account, with feed: WebFeed, to container: Container, completion: @escaping (Result<Void, Error>) -> ()) {
|
||||||
|
guard let folder = container as? Folder else {
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
if let account = container as? Account {
|
||||||
|
account.addWebFeed(feed)
|
||||||
|
}
|
||||||
|
completion(.success(()))
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let folderName = folder.name ?? ""
|
||||||
|
saveFolderRelationship(for: feed, withFolderName: folderName, id: folderName)
|
||||||
|
folder.addWebFeed(feed)
|
||||||
|
|
||||||
|
completion(.success(()))
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeWebFeed(for account: Account, with feed: WebFeed, from container: Container, completion: @escaping (Result<Void, Error>) -> ()) {
|
||||||
|
deleteFeed(for: account, with: feed, from: container, completion: completion)
|
||||||
|
}
|
||||||
|
|
||||||
|
func moveWebFeed(for account: Account, with feed: WebFeed, from: Container, to: Container, completion: @escaping (Result<Void, Error>) -> ()) {
|
||||||
|
guard let feedID = feed.externalID else {
|
||||||
|
completion(.failure(NewsBlurError.invalidParameter))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshProgress.addToNumberOfTasksAndRemaining(1)
|
||||||
|
|
||||||
|
caller.moveFeed(
|
||||||
|
feedID: feedID,
|
||||||
|
from: (from as? Folder)?.name,
|
||||||
|
to: (to as? Folder)?.name
|
||||||
|
) { result in
|
||||||
|
self.refreshProgress.completeTask()
|
||||||
|
|
||||||
|
switch result {
|
||||||
|
case .success:
|
||||||
|
from.removeWebFeed(feed)
|
||||||
|
to.addWebFeed(feed)
|
||||||
|
|
||||||
|
completion(.success(()))
|
||||||
|
case .failure(let error):
|
||||||
|
completion(.failure(error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func restoreWebFeed(for account: Account, feed: WebFeed, container: Container, completion: @escaping (Result<Void, Error>) -> ()) {
|
||||||
|
if let existingFeed = account.existingWebFeed(withURL: feed.url) {
|
||||||
|
account.addWebFeed(existingFeed, to: container) { result in
|
||||||
|
switch result {
|
||||||
|
case .success:
|
||||||
|
completion(.success(()))
|
||||||
|
case .failure(let error):
|
||||||
|
completion(.failure(error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
createWebFeed(for: account, url: feed.url, name: feed.editedName, container: container) { result in
|
||||||
|
switch result {
|
||||||
|
case .success:
|
||||||
|
completion(.success(()))
|
||||||
|
case .failure(let error):
|
||||||
|
completion(.failure(error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func restoreFolder(for account: Account, folder: Folder, completion: @escaping (Result<Void, Error>) -> ()) {
|
||||||
|
guard let folderName = folder.name else {
|
||||||
|
completion(.failure(NewsBlurError.invalidParameter))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var feedsToRestore: [WebFeed] = []
|
||||||
|
for feed in folder.topLevelWebFeeds {
|
||||||
|
feedsToRestore.append(feed)
|
||||||
|
folder.topLevelWebFeeds.remove(feed)
|
||||||
|
}
|
||||||
|
|
||||||
|
let group = DispatchGroup()
|
||||||
|
|
||||||
|
group.enter()
|
||||||
|
addFolder(for: account, name: folderName) { result in
|
||||||
|
group.leave()
|
||||||
|
switch result {
|
||||||
|
case .success(let folder):
|
||||||
|
for feed in feedsToRestore {
|
||||||
|
group.enter()
|
||||||
|
self.restoreWebFeed(for: account, feed: feed, container: folder) { result in
|
||||||
|
group.leave()
|
||||||
|
switch result {
|
||||||
|
case .success:
|
||||||
|
break
|
||||||
|
case .failure(let error):
|
||||||
|
os_log(.error, log: self.log, "Restore folder feed error: %@.", error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case .failure(let error):
|
||||||
|
os_log(.error, log: self.log, "Restore folder feed error: %@.", error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
group.notify(queue: DispatchQueue.main) {
|
||||||
|
completion(.success(()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
credentials = try? account.retrieveCredentials(type: .newsBlurSessionId)
|
||||||
|
}
|
||||||
|
|
||||||
|
func accountWillBeDeleted(_ account: Account) {
|
||||||
|
caller.logout() { _ in }
|
||||||
|
}
|
||||||
|
|
||||||
|
class func validateCredentials(transport: Transport, credentials: Credentials, endpoint: URL? = nil, completion: @escaping (Result<Credentials?, Error>) -> ()) {
|
||||||
|
let caller = NewsBlurAPICaller(transport: transport)
|
||||||
|
caller.credentials = credentials
|
||||||
|
caller.validateCredentials() { result in
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
completion(result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Suspend and Resume (for iOS)
|
||||||
|
|
||||||
|
/// Suspend all network activity
|
||||||
|
func suspendNetwork() {
|
||||||
|
caller.suspend()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Suspend the SQLLite databases
|
||||||
|
func suspendDatabase() {
|
||||||
|
database.suspend()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Make sure no SQLite databases are open and we are ready to issue network requests.
|
||||||
|
func resume() {
|
||||||
|
caller.resume()
|
||||||
|
database.resume()
|
||||||
|
}
|
||||||
|
}
|
@ -466,7 +466,6 @@
|
|||||||
65ED405D235DEF6C0081F399 /* SidebarKeyboardShortcuts.plist in Resources */ = {isa = PBXBuildFile; fileRef = 844B5B681FEA20DF00C7C76A /* SidebarKeyboardShortcuts.plist */; };
|
65ED405D235DEF6C0081F399 /* SidebarKeyboardShortcuts.plist in Resources */ = {isa = PBXBuildFile; fileRef = 844B5B681FEA20DF00C7C76A /* SidebarKeyboardShortcuts.plist */; };
|
||||||
65ED405E235DEF6C0081F399 /* DefaultFeeds.opml in Resources */ = {isa = PBXBuildFile; fileRef = 84A3EE52223B667F00557320 /* DefaultFeeds.opml */; };
|
65ED405E235DEF6C0081F399 /* DefaultFeeds.opml in Resources */ = {isa = PBXBuildFile; fileRef = 84A3EE52223B667F00557320 /* DefaultFeeds.opml */; };
|
||||||
65ED405F235DEF6C0081F399 /* Preferences.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 84C9FC8022629E4800D921D6 /* Preferences.storyboard */; };
|
65ED405F235DEF6C0081F399 /* Preferences.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 84C9FC8022629E4800D921D6 /* Preferences.storyboard */; };
|
||||||
65ED4060235DEF6C0081F399 /* (null) in Resources */ = {isa = PBXBuildFile; };
|
|
||||||
65ED4061235DEF6C0081F399 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 849C64671ED37A5D003D8FC0 /* Assets.xcassets */; };
|
65ED4061235DEF6C0081F399 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 849C64671ED37A5D003D8FC0 /* Assets.xcassets */; };
|
||||||
65ED4062235DEF6C0081F399 /* styleSheet.css in Resources */ = {isa = PBXBuildFile; fileRef = 848362FC2262A30800DA1D35 /* styleSheet.css */; };
|
65ED4062235DEF6C0081F399 /* styleSheet.css in Resources */ = {isa = PBXBuildFile; fileRef = 848362FC2262A30800DA1D35 /* styleSheet.css */; };
|
||||||
65ED4063235DEF6C0081F399 /* RenameSheet.xib in Resources */ = {isa = PBXBuildFile; fileRef = 848363092262A3F000DA1D35 /* RenameSheet.xib */; };
|
65ED4063235DEF6C0081F399 /* RenameSheet.xib in Resources */ = {isa = PBXBuildFile; fileRef = 848363092262A3F000DA1D35 /* RenameSheet.xib */; };
|
||||||
@ -505,6 +504,7 @@
|
|||||||
65ED42DD235E74230081F399 /* org.sparkle-project.InstallerStatus.xpc in Embed XPC Services */ = {isa = PBXBuildFile; fileRef = 65ED42BA235E71B40081F399 /* org.sparkle-project.InstallerStatus.xpc */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
65ED42DD235E74230081F399 /* org.sparkle-project.InstallerStatus.xpc in Embed XPC Services */ = {isa = PBXBuildFile; fileRef = 65ED42BA235E71B40081F399 /* org.sparkle-project.InstallerStatus.xpc */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||||
65ED42DE235E74230081F399 /* Sparkle.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 65ED42B0235E71B40081F399 /* Sparkle.framework */; };
|
65ED42DE235E74230081F399 /* Sparkle.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 65ED42B0235E71B40081F399 /* Sparkle.framework */; };
|
||||||
65ED42DF235E74230081F399 /* Sparkle.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 65ED42B0235E71B40081F399 /* Sparkle.framework */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
65ED42DF235E74230081F399 /* Sparkle.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 65ED42B0235E71B40081F399 /* Sparkle.framework */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||||
|
769F2ED513DA03EE75B993A8 /* NewsBlurAccountViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 769F2D3643779DB02786278E /* NewsBlurAccountViewController.swift */; };
|
||||||
8405DD8A2213E0E3008CE1BF /* DetailContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8405DD892213E0E3008CE1BF /* DetailContainerView.swift */; };
|
8405DD8A2213E0E3008CE1BF /* DetailContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8405DD892213E0E3008CE1BF /* DetailContainerView.swift */; };
|
||||||
8405DD9922153B6B008CE1BF /* TimelineContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8405DD9822153B6B008CE1BF /* TimelineContainerView.swift */; };
|
8405DD9922153B6B008CE1BF /* TimelineContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8405DD9822153B6B008CE1BF /* TimelineContainerView.swift */; };
|
||||||
8405DD9C22153BD7008CE1BF /* NSView-Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8405DD9B22153BD7008CE1BF /* NSView-Extensions.swift */; };
|
8405DD9C22153BD7008CE1BF /* NSView-Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8405DD9B22153BD7008CE1BF /* NSView-Extensions.swift */; };
|
||||||
@ -1454,6 +1454,7 @@
|
|||||||
65ED40F2235DF5E00081F399 /* NetNewsWire_macapp_target_macappstore.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = NetNewsWire_macapp_target_macappstore.xcconfig; sourceTree = "<group>"; };
|
65ED40F2235DF5E00081F399 /* NetNewsWire_macapp_target_macappstore.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = NetNewsWire_macapp_target_macappstore.xcconfig; sourceTree = "<group>"; };
|
||||||
65ED4186235E045B0081F399 /* NetNewsWire_safariextension_target_macappstore.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = NetNewsWire_safariextension_target_macappstore.xcconfig; sourceTree = "<group>"; };
|
65ED4186235E045B0081F399 /* NetNewsWire_safariextension_target_macappstore.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = NetNewsWire_safariextension_target_macappstore.xcconfig; sourceTree = "<group>"; };
|
||||||
65ED4299235E71B40081F399 /* Sparkle.xcodeproj */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.pb-project"; name = Sparkle.xcodeproj; path = submodules/Sparkle/Sparkle.xcodeproj; sourceTree = SOURCE_ROOT; };
|
65ED4299235E71B40081F399 /* Sparkle.xcodeproj */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.pb-project"; name = Sparkle.xcodeproj; path = submodules/Sparkle/Sparkle.xcodeproj; sourceTree = SOURCE_ROOT; };
|
||||||
|
769F2D3643779DB02786278E /* NewsBlurAccountViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NewsBlurAccountViewController.swift; sourceTree = "<group>"; };
|
||||||
8405DD892213E0E3008CE1BF /* DetailContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetailContainerView.swift; sourceTree = "<group>"; };
|
8405DD892213E0E3008CE1BF /* DetailContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetailContainerView.swift; sourceTree = "<group>"; };
|
||||||
8405DD9822153B6B008CE1BF /* TimelineContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineContainerView.swift; sourceTree = "<group>"; };
|
8405DD9822153B6B008CE1BF /* TimelineContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineContainerView.swift; sourceTree = "<group>"; };
|
||||||
8405DD9B22153BD7008CE1BF /* NSView-Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSView-Extensions.swift"; sourceTree = "<group>"; };
|
8405DD9B22153BD7008CE1BF /* NSView-Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSView-Extensions.swift"; sourceTree = "<group>"; };
|
||||||
@ -1874,6 +1875,7 @@
|
|||||||
51A1698F235E10D600EB091F /* LocalAccountViewController.swift */,
|
51A1698F235E10D600EB091F /* LocalAccountViewController.swift */,
|
||||||
51A16996235E10D700EB091F /* FeedbinAccountViewController.swift */,
|
51A16996235E10D700EB091F /* FeedbinAccountViewController.swift */,
|
||||||
3B3A328B238B820900314204 /* FeedWranglerAccountViewController.swift */,
|
3B3A328B238B820900314204 /* FeedWranglerAccountViewController.swift */,
|
||||||
|
769F2D3643779DB02786278E /* NewsBlurAccountViewController.swift */,
|
||||||
);
|
);
|
||||||
path = Account;
|
path = Account;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@ -3487,7 +3489,6 @@
|
|||||||
5103A9F5242258C600410853 /* AccountsAddCloudKit.xib in Resources */,
|
5103A9F5242258C600410853 /* AccountsAddCloudKit.xib in Resources */,
|
||||||
65ED405E235DEF6C0081F399 /* DefaultFeeds.opml in Resources */,
|
65ED405E235DEF6C0081F399 /* DefaultFeeds.opml in Resources */,
|
||||||
65ED405F235DEF6C0081F399 /* Preferences.storyboard in Resources */,
|
65ED405F235DEF6C0081F399 /* Preferences.storyboard in Resources */,
|
||||||
65ED4060235DEF6C0081F399 /* (null) in Resources */,
|
|
||||||
65ED4061235DEF6C0081F399 /* Assets.xcassets in Resources */,
|
65ED4061235DEF6C0081F399 /* Assets.xcassets in Resources */,
|
||||||
65ED4062235DEF6C0081F399 /* styleSheet.css in Resources */,
|
65ED4062235DEF6C0081F399 /* styleSheet.css in Resources */,
|
||||||
65ED4063235DEF6C0081F399 /* RenameSheet.xib in Resources */,
|
65ED4063235DEF6C0081F399 /* RenameSheet.xib in Resources */,
|
||||||
@ -4069,6 +4070,7 @@
|
|||||||
51C45293226509C800C03939 /* StarredFeedDelegate.swift in Sources */,
|
51C45293226509C800C03939 /* StarredFeedDelegate.swift in Sources */,
|
||||||
51D6A5BC23199C85001C27D8 /* MasterTimelineDataSource.swift in Sources */,
|
51D6A5BC23199C85001C27D8 /* MasterTimelineDataSource.swift in Sources */,
|
||||||
51934CCB230F599B006127BE /* InteractiveNavigationController.swift in Sources */,
|
51934CCB230F599B006127BE /* InteractiveNavigationController.swift in Sources */,
|
||||||
|
769F2ED513DA03EE75B993A8 /* NewsBlurAccountViewController.swift in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
@ -406,6 +406,155 @@
|
|||||||
</objects>
|
</objects>
|
||||||
<point key="canvasLocation" x="3177" y="145"/>
|
<point key="canvasLocation" x="3177" y="145"/>
|
||||||
</scene>
|
</scene>
|
||||||
|
<!--Modal Navigation Controller-->
|
||||||
|
<scene sceneID="j4N-ax-exh">
|
||||||
|
<objects>
|
||||||
|
<navigationController storyboardIdentifier="NewsBlurAccountNavigationViewController" id="eE3-pu-HdL" customClass="ModalNavigationController" customModule="NetNewsWire" customModuleProvider="target" sceneMemberID="viewController">
|
||||||
|
<navigationBar key="navigationBar" contentMode="scaleToFill" insetsLayoutMarginsFromSafeArea="NO" id="Fsp-NG-hoR">
|
||||||
|
<rect key="frame" x="0.0" y="44" width="414" height="44"/>
|
||||||
|
<autoresizingMask key="autoresizingMask"/>
|
||||||
|
</navigationBar>
|
||||||
|
<connections>
|
||||||
|
<segue destination="Cge-ND-NpD" kind="relationship" relationship="rootViewController" id="1D5-CN-liN"/>
|
||||||
|
</connections>
|
||||||
|
</navigationController>
|
||||||
|
<placeholder placeholderIdentifier="IBFirstResponder" id="8t3-0U-5vL" userLabel="First Responder" sceneMemberID="firstResponder"/>
|
||||||
|
</objects>
|
||||||
|
<point key="canvasLocation" x="4562" y="-528"/>
|
||||||
|
</scene>
|
||||||
|
<!--NewsBlur-->
|
||||||
|
<scene sceneID="tfA-kz-P6O">
|
||||||
|
<objects>
|
||||||
|
<tableViewController id="Cge-ND-NpD" customClass="NewsBlurAccountViewController" 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="fLL-7i-HdK">
|
||||||
|
<rect key="frame" x="0.0" y="0.0" width="414" height="896"/>
|
||||||
|
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||||
|
<sections>
|
||||||
|
<tableViewSection id="I5T-12-2jC">
|
||||||
|
<cells>
|
||||||
|
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="none" indentationWidth="10" id="gAY-Bo-c0L">
|
||||||
|
<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="gAY-Bo-c0L" id="mqD-6S-DIl">
|
||||||
|
<rect key="frame" x="0.0" y="0.0" width="374" height="43.5"/>
|
||||||
|
<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"/>
|
||||||
|
<fontDescription key="fontDescription" style="UICTFontTextStyleBody"/>
|
||||||
|
<textInputTraits key="textInputTraits" spellCheckingType="no" keyboardType="emailAddress" textContentType="username"/>
|
||||||
|
</textField>
|
||||||
|
</subviews>
|
||||||
|
<constraints>
|
||||||
|
<constraint firstAttribute="trailing" secondItem="S4v-fs-DIO" secondAttribute="trailing" constant="20" id="Upe-dm-4DP"/>
|
||||||
|
<constraint firstItem="S4v-fs-DIO" firstAttribute="leading" secondItem="mqD-6S-DIl" secondAttribute="leading" constant="20" id="pQc-Fh-6T3"/>
|
||||||
|
<constraint firstItem="S4v-fs-DIO" firstAttribute="centerY" secondItem="mqD-6S-DIl" secondAttribute="centerY" id="s9a-ew-C5W"/>
|
||||||
|
</constraints>
|
||||||
|
</tableViewCellContentView>
|
||||||
|
</tableViewCell>
|
||||||
|
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="none" indentationWidth="10" id="iCK-kn-Au6">
|
||||||
|
<rect key="frame" x="20" y="61.5" width="374" height="43.5"/>
|
||||||
|
<autoresizingMask key="autoresizingMask"/>
|
||||||
|
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="iCK-kn-Au6" id="9Ej-wB-9Tr">
|
||||||
|
<rect key="frame" x="0.0" y="0.0" width="374" height="43.5"/>
|
||||||
|
<autoresizingMask key="autoresizingMask"/>
|
||||||
|
<subviews>
|
||||||
|
<textField opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="left" contentVerticalAlignment="center" placeholder="Password" textAlignment="natural" adjustsFontForContentSizeCategory="YES" minimumFontSize="17" translatesAutoresizingMaskIntoConstraints="NO" id="fct-XR-fEa">
|
||||||
|
<rect key="frame" x="20" y="11.5" width="283" height="21"/>
|
||||||
|
<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="GY9-nr-jFb">
|
||||||
|
<rect key="frame" x="311" y="5.5" width="43" height="33"/>
|
||||||
|
<fontDescription key="fontDescription" style="UICTFontTextStyleBody"/>
|
||||||
|
<state key="normal" title="Show"/>
|
||||||
|
<connections>
|
||||||
|
<action selector="showHidePassword:" destination="Cge-ND-NpD" eventType="touchUpInside" id="8JH-LX-URH"/>
|
||||||
|
</connections>
|
||||||
|
</button>
|
||||||
|
</subviews>
|
||||||
|
<constraints>
|
||||||
|
<constraint firstItem="GY9-nr-jFb" firstAttribute="centerY" secondItem="9Ej-wB-9Tr" secondAttribute="centerY" id="3jf-KC-nd8"/>
|
||||||
|
<constraint firstItem="GY9-nr-jFb" firstAttribute="leading" secondItem="fct-XR-fEa" secondAttribute="trailing" constant="8" symbolic="YES" id="Ibr-pt-eGr"/>
|
||||||
|
<constraint firstAttribute="trailing" secondItem="GY9-nr-jFb" secondAttribute="trailing" constant="20" symbolic="YES" id="mcZ-cl-knP"/>
|
||||||
|
<constraint firstItem="fct-XR-fEa" firstAttribute="leading" secondItem="9Ej-wB-9Tr" secondAttribute="leading" constant="20" id="u5f-tJ-8ce"/>
|
||||||
|
<constraint firstItem="fct-XR-fEa" firstAttribute="centerY" secondItem="9Ej-wB-9Tr" secondAttribute="centerY" id="z5e-jg-0nm"/>
|
||||||
|
</constraints>
|
||||||
|
</tableViewCellContentView>
|
||||||
|
</tableViewCell>
|
||||||
|
</cells>
|
||||||
|
</tableViewSection>
|
||||||
|
<tableViewSection id="L37-iZ-GVj">
|
||||||
|
<cells>
|
||||||
|
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="none" indentationWidth="10" id="fyQ-K8-byV">
|
||||||
|
<rect key="frame" x="20" y="141" width="374" height="43.5"/>
|
||||||
|
<autoresizingMask key="autoresizingMask"/>
|
||||||
|
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="fyQ-K8-byV" id="CtR-ZJ-FG5">
|
||||||
|
<rect key="frame" x="0.0" y="0.0" width="374" height="43.5"/>
|
||||||
|
<autoresizingMask key="autoresizingMask"/>
|
||||||
|
<subviews>
|
||||||
|
<button opaque="NO" contentMode="scaleToFill" enabled="NO" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="E1I-C4-JdL" 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="yoo-36-msf"/>
|
||||||
|
</constraints>
|
||||||
|
<fontDescription key="fontDescription" style="UICTFontTextStyleBody"/>
|
||||||
|
<state key="normal" title="Action">
|
||||||
|
<color key="titleColor" name="secondaryAccentColor"/>
|
||||||
|
</state>
|
||||||
|
<connections>
|
||||||
|
<action selector="action:" destination="Cge-ND-NpD" eventType="touchUpInside" id="YQw-1k-e8G"/>
|
||||||
|
</connections>
|
||||||
|
</button>
|
||||||
|
</subviews>
|
||||||
|
<constraints>
|
||||||
|
<constraint firstItem="E1I-C4-JdL" firstAttribute="centerY" secondItem="CtR-ZJ-FG5" secondAttribute="centerY" id="2vc-Ys-4Cj"/>
|
||||||
|
<constraint firstAttribute="trailing" secondItem="E1I-C4-JdL" secondAttribute="trailing" id="SLX-wc-QR7"/>
|
||||||
|
<constraint firstItem="E1I-C4-JdL" firstAttribute="leading" secondItem="CtR-ZJ-FG5" secondAttribute="leading" id="Veu-Wo-GSZ"/>
|
||||||
|
</constraints>
|
||||||
|
</tableViewCellContentView>
|
||||||
|
</tableViewCell>
|
||||||
|
</cells>
|
||||||
|
</tableViewSection>
|
||||||
|
</sections>
|
||||||
|
<connections>
|
||||||
|
<outlet property="dataSource" destination="Cge-ND-NpD" id="u8B-p4-Vlv"/>
|
||||||
|
<outlet property="delegate" destination="Cge-ND-NpD" id="RIw-V2-EJC"/>
|
||||||
|
</connections>
|
||||||
|
</tableView>
|
||||||
|
<navigationItem key="navigationItem" title="NewsBlur" id="jCQ-pH-6AD">
|
||||||
|
<barButtonItem key="leftBarButtonItem" systemItem="cancel" id="bl6-Y1-wQ8">
|
||||||
|
<connections>
|
||||||
|
<action selector="cancel:" destination="Cge-ND-NpD" id="9zR-LJ-IWk"/>
|
||||||
|
</connections>
|
||||||
|
</barButtonItem>
|
||||||
|
<barButtonItem key="rightBarButtonItem" id="4yi-H0-B9J">
|
||||||
|
<view key="customView" contentMode="scaleToFill" id="8DU-L0-P6c">
|
||||||
|
<rect key="frame" x="374" y="12" width="20" height="20"/>
|
||||||
|
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
|
||||||
|
<subviews>
|
||||||
|
<activityIndicatorView opaque="NO" contentMode="scaleToFill" horizontalHuggingPriority="750" verticalHuggingPriority="750" fixedFrame="YES" style="medium" translatesAutoresizingMaskIntoConstraints="NO" id="HfW-jV-MjK">
|
||||||
|
<rect key="frame" x="36" y="6" width="20" height="20"/>
|
||||||
|
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
|
||||||
|
</activityIndicatorView>
|
||||||
|
</subviews>
|
||||||
|
<color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||||
|
</view>
|
||||||
|
</barButtonItem>
|
||||||
|
</navigationItem>
|
||||||
|
<connections>
|
||||||
|
<outlet property="actionButton" destination="E1I-C4-JdL" id="q2T-4o-c8i"/>
|
||||||
|
<outlet property="activityIndicator" destination="HfW-jV-MjK" id="AIV-uG-9uC"/>
|
||||||
|
<outlet property="cancelBarButtonItem" destination="bl6-Y1-wQ8" id="ohR-gW-5J2"/>
|
||||||
|
<outlet property="passwordTextField" destination="fct-XR-fEa" id="fGL-4k-gZ6"/>
|
||||||
|
<outlet property="showHideButton" destination="GY9-nr-jFb" id="1p9-9F-GMY"/>
|
||||||
|
<outlet property="usernameTextField" destination="S4v-fs-DIO" id="B7I-yz-M0T"/>
|
||||||
|
</connections>
|
||||||
|
</tableViewController>
|
||||||
|
<placeholder placeholderIdentifier="IBFirstResponder" id="8Ku-6P-yPg" userLabel="First Responder" customClass="UIResponder" sceneMemberID="firstResponder"/>
|
||||||
|
</objects>
|
||||||
|
<point key="canvasLocation" x="4562" y="145"/>
|
||||||
|
</scene>
|
||||||
</scenes>
|
</scenes>
|
||||||
<resources>
|
<resources>
|
||||||
<namedColor name="secondaryAccentColor">
|
<namedColor name="secondaryAccentColor">
|
||||||
|
175
iOS/Account/NewsBlurAccountViewController.swift
Normal file
175
iOS/Account/NewsBlurAccountViewController.swift
Normal file
@ -0,0 +1,175 @@
|
|||||||
|
//
|
||||||
|
// NewsBlurAccountViewController.swift
|
||||||
|
// NetNewsWire
|
||||||
|
//
|
||||||
|
// Created by Anh-Quang Do on 3/9/20.
|
||||||
|
// Copyright (c) 2020 Ranchero Software. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
import Account
|
||||||
|
import RSWeb
|
||||||
|
|
||||||
|
class NewsBlurAccountViewController: UITableViewController {
|
||||||
|
|
||||||
|
@IBOutlet weak var activityIndicator: UIActivityIndicatorView!
|
||||||
|
@IBOutlet weak var cancelBarButtonItem: UIBarButtonItem!
|
||||||
|
@IBOutlet weak var usernameTextField: UITextField!
|
||||||
|
@IBOutlet weak var passwordTextField: UITextField!
|
||||||
|
@IBOutlet weak var showHideButton: UIButton!
|
||||||
|
@IBOutlet weak var actionButton: UIButton!
|
||||||
|
|
||||||
|
weak var account: Account?
|
||||||
|
weak var delegate: AddAccountDismissDelegate?
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
|
||||||
|
activityIndicator.isHidden = true
|
||||||
|
usernameTextField.delegate = self
|
||||||
|
passwordTextField.delegate = self
|
||||||
|
|
||||||
|
if let account = account, let credentials = try? account.retrieveCredentials(type: .basic) {
|
||||||
|
actionButton.setTitle(NSLocalizedString("Update Credentials", comment: "Update Credentials"), for: .normal)
|
||||||
|
actionButton.isEnabled = true
|
||||||
|
usernameTextField.text = credentials.username
|
||||||
|
passwordTextField.text = credentials.secret
|
||||||
|
} else {
|
||||||
|
actionButton.setTitle(NSLocalizedString("Add Account", comment: "Add Account"), for: .normal)
|
||||||
|
}
|
||||||
|
|
||||||
|
NotificationCenter.default.addObserver(self, selector: #selector(textDidChange(_:)), name: UITextField.textDidChangeNotification, object: usernameTextField)
|
||||||
|
NotificationCenter.default.addObserver(self, selector: #selector(textDidChange(_:)), name: UITextField.textDidChangeNotification, object: passwordTextField)
|
||||||
|
|
||||||
|
tableView.register(ImageHeaderView.self, forHeaderFooterViewReuseIdentifier: "SectionHeader")
|
||||||
|
}
|
||||||
|
|
||||||
|
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: .newsBlur)
|
||||||
|
return headerView
|
||||||
|
} else {
|
||||||
|
return super.tableView(tableView, viewForHeaderInSection: section)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@IBAction func cancel(_ sender: Any) {
|
||||||
|
dismiss(animated: true, completion: nil)
|
||||||
|
delegate?.dismiss()
|
||||||
|
}
|
||||||
|
|
||||||
|
@IBAction func showHidePassword(_ sender: Any) {
|
||||||
|
if passwordTextField.isSecureTextEntry {
|
||||||
|
passwordTextField.isSecureTextEntry = false
|
||||||
|
showHideButton.setTitle("Hide", for: .normal)
|
||||||
|
} else {
|
||||||
|
passwordTextField.isSecureTextEntry = true
|
||||||
|
showHideButton.setTitle("Show", for: .normal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@IBAction func action(_ sender: Any) {
|
||||||
|
|
||||||
|
guard let username = usernameTextField.text else {
|
||||||
|
showError(NSLocalizedString("Username required.", comment: "Credentials Error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let password = passwordTextField.text ?? ""
|
||||||
|
|
||||||
|
startAnimatingActivityIndicator()
|
||||||
|
disableNavigation()
|
||||||
|
|
||||||
|
// When you fill in the email address via auto-complete it adds extra whitespace
|
||||||
|
let trimmedUsername = username.trimmingCharacters(in: .whitespaces)
|
||||||
|
let credentials = Credentials(type: .newsBlurBasic, username: trimmedUsername, secret: password)
|
||||||
|
Account.validateCredentials(type: .newsBlur, credentials: credentials) { result in
|
||||||
|
|
||||||
|
self.stopAnimtatingActivityIndicator()
|
||||||
|
self.enableNavigation()
|
||||||
|
|
||||||
|
switch result {
|
||||||
|
case .success(let credentials):
|
||||||
|
if let credentials = credentials {
|
||||||
|
var newAccount = false
|
||||||
|
if self.account == nil {
|
||||||
|
self.account = AccountManager.shared.createAccount(type: .newsBlur)
|
||||||
|
newAccount = true
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
|
||||||
|
do {
|
||||||
|
try self.account?.removeCredentials(type: .basic)
|
||||||
|
} catch {}
|
||||||
|
try self.account?.storeCredentials(credentials)
|
||||||
|
|
||||||
|
if newAccount {
|
||||||
|
self.account?.refreshAll() { result in
|
||||||
|
switch result {
|
||||||
|
case .success:
|
||||||
|
break
|
||||||
|
case .failure(let error):
|
||||||
|
self.presentError(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.dismiss(animated: true, completion: nil)
|
||||||
|
self.delegate?.dismiss()
|
||||||
|
} catch {
|
||||||
|
self.showError(NSLocalizedString("Keychain error while storing credentials.", comment: "Credentials Error"))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
self.showError(NSLocalizedString("Invalid username/password combination.", comment: "Credentials Error"))
|
||||||
|
}
|
||||||
|
case .failure(let error):
|
||||||
|
self.showError(error.localizedDescription)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc func textDidChange(_ note: Notification) {
|
||||||
|
actionButton.isEnabled = !(usernameTextField.text?.isEmpty ?? false)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func showError(_ message: String) {
|
||||||
|
presentError(title: "Error", message: message)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func enableNavigation() {
|
||||||
|
self.cancelBarButtonItem.isEnabled = true
|
||||||
|
self.actionButton.isEnabled = true
|
||||||
|
}
|
||||||
|
|
||||||
|
private func disableNavigation() {
|
||||||
|
cancelBarButtonItem.isEnabled = false
|
||||||
|
actionButton.isEnabled = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private func startAnimatingActivityIndicator() {
|
||||||
|
activityIndicator.isHidden = false
|
||||||
|
activityIndicator.startAnimating()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func stopAnimtatingActivityIndicator() {
|
||||||
|
self.activityIndicator.isHidden = true
|
||||||
|
self.activityIndicator.stopAnimating()
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
extension NewsBlurAccountViewController: UITextFieldDelegate {
|
||||||
|
|
||||||
|
func textFieldShouldReturn(_ textField: UITextField) -> Bool {
|
||||||
|
textField.resignFirstResponder()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -35,6 +35,10 @@ struct AppAssets {
|
|||||||
return UIImage(named: "accountFreshRSS")!
|
return UIImage(named: "accountFreshRSS")!
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
static var accountNewsBlurImage: UIImage = {
|
||||||
|
return UIImage(named: "accountNewsBlur")!
|
||||||
|
}()
|
||||||
|
|
||||||
static var articleExtractorError: UIImage = {
|
static var articleExtractorError: UIImage = {
|
||||||
return UIImage(named: "articleExtractorError")!
|
return UIImage(named: "articleExtractorError")!
|
||||||
}()
|
}()
|
||||||
@ -238,6 +242,8 @@ struct AppAssets {
|
|||||||
return AppAssets.accountFeedWranglerImage
|
return AppAssets.accountFeedWranglerImage
|
||||||
case .freshRSS:
|
case .freshRSS:
|
||||||
return AppAssets.accountFreshRSSImage
|
return AppAssets.accountFreshRSSImage
|
||||||
|
case .newsBlur:
|
||||||
|
return AppAssets.accountNewsBlurImage
|
||||||
default:
|
default:
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
15
iOS/Resources/Assets.xcassets/accountNewsBlur.imageset/Contents.json
vendored
Normal file
15
iOS/Resources/Assets.xcassets/accountNewsBlur.imageset/Contents.json
vendored
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"filename" : "newsblur-512.png"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"version" : 1,
|
||||||
|
"author" : "xcode"
|
||||||
|
},
|
||||||
|
"properties" : {
|
||||||
|
"template-rendering-intent" : "template"
|
||||||
|
}
|
||||||
|
}
|
BIN
iOS/Resources/Assets.xcassets/accountNewsBlur.imageset/newsblur-512.png
vendored
Normal file
BIN
iOS/Resources/Assets.xcassets/accountNewsBlur.imageset/newsblur-512.png
vendored
Normal file
Binary file not shown.
After Width: | Height: | Size: 53 KiB |
@ -56,6 +56,12 @@ class AddAccountViewController: UITableViewController, AddAccountDismissDelegate
|
|||||||
let addViewController = navController.topViewController as! FeedWranglerAccountViewController
|
let addViewController = navController.topViewController as! FeedWranglerAccountViewController
|
||||||
addViewController.delegate = self
|
addViewController.delegate = self
|
||||||
present(navController, animated: true)
|
present(navController, animated: true)
|
||||||
|
case 4:
|
||||||
|
let navController = UIStoryboard.account.instantiateViewController(withIdentifier: "NewsBlurAccountNavigationViewController") as! UINavigationController
|
||||||
|
navController.modalPresentationStyle = .currentContext
|
||||||
|
let addViewController = navController.topViewController as! NewsBlurAccountViewController
|
||||||
|
addViewController.delegate = self
|
||||||
|
present(navController, animated: true)
|
||||||
default:
|
default:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
@ -677,6 +677,43 @@
|
|||||||
<outlet property="accountNameLabel" destination="Dur-Qf-YYi" id="DAF-c9-MJM"/>
|
<outlet property="accountNameLabel" destination="Dur-Qf-YYi" id="DAF-c9-MJM"/>
|
||||||
</connections>
|
</connections>
|
||||||
</tableViewCell>
|
</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>
|
</cells>
|
||||||
</tableViewSection>
|
</tableViewSection>
|
||||||
</sections>
|
</sections>
|
||||||
@ -1050,6 +1087,7 @@
|
|||||||
<image name="accountFeedbin" width="120" height="102"/>
|
<image name="accountFeedbin" width="120" height="102"/>
|
||||||
<image name="accountFeedly" width="138" height="123"/>
|
<image name="accountFeedly" width="138" height="123"/>
|
||||||
<image name="accountLocal" width="99" height="77"/>
|
<image name="accountLocal" width="99" height="77"/>
|
||||||
|
<image name="accountNewsBlur" width="512" height="512"/>
|
||||||
<namedColor name="primaryAccentColor">
|
<namedColor name="primaryAccentColor">
|
||||||
<color red="0.031372549019607843" green="0.41568627450980394" blue="0.93333333333333335" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
<color red="0.031372549019607843" green="0.41568627450980394" blue="0.93333333333333335" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
||||||
</namedColor>
|
</namedColor>
|
||||||
|
Loading…
x
Reference in New Issue
Block a user