Compare commits
10 Commits
0e1629fe84
...
a17867e6d4
Author | SHA1 | Date |
---|---|---|
Andy Williams | a17867e6d4 | |
Brent Simmons | ab4d51b462 | |
Brent Simmons | a474a8fc18 | |
Brent Simmons | d58821a7ad | |
Brent Simmons | be4564716f | |
Brent Simmons | a3151181eb | |
Brent Simmons | 48bfcedbf7 | |
Brent Simmons | ae77aece2a | |
Brent Simmons | 138177858c | |
Andy Williams | 1f1bbc8b26 |
|
@ -408,7 +408,7 @@ enum CloudKitAccountDelegateError: LocalizedError {
|
|||
SyncStatus(articleID: article.articleID, key: SyncStatus.Key(statusKey), flag: flag)
|
||||
}
|
||||
|
||||
try? await database.insertStatuses(syncStatuses)
|
||||
try? await database.insertStatuses(Set(syncStatuses))
|
||||
if let count = try? await self.database.selectPendingCount(), count > 100 {
|
||||
try await sendArticleStatus(for: account, showProgress: false)
|
||||
}
|
||||
|
@ -671,7 +671,7 @@ private extension CloudKitAccountDelegate {
|
|||
return SyncStatus(articleID: article.articleID, key: statusKey, flag: flag)
|
||||
}
|
||||
|
||||
try? await database.insertStatuses(syncStatuses)
|
||||
try? await database.insertStatuses(Set(syncStatuses))
|
||||
}
|
||||
|
||||
func sendArticleStatus(for account: Account, showProgress: Bool) async throws {
|
||||
|
|
|
@ -66,7 +66,7 @@ private extension CloudKitArticlesZoneDelegate {
|
|||
return
|
||||
}
|
||||
|
||||
try? await database.deleteSelectedForProcessing(Array(deletableArticleIDs))
|
||||
try? await database.deleteSelectedForProcessing(deletableArticleIDs)
|
||||
try? await account?.delete(articleIDs: deletableArticleIDs)
|
||||
}
|
||||
|
||||
|
@ -124,7 +124,7 @@ private extension CloudKitArticlesZoneDelegate {
|
|||
}
|
||||
|
||||
let syncStatuses = deletes.map { SyncStatus(articleID: $0.articleID, key: .deleted, flag: true) }
|
||||
try? await self.database.insertStatuses(syncStatuses)
|
||||
try? await self.database.insertStatuses(Set(syncStatuses))
|
||||
|
||||
} catch {
|
||||
errorOccurred = true
|
||||
|
|
|
@ -378,7 +378,7 @@ public enum FeedbinAccountDelegateError: String, Error {
|
|||
return SyncStatus(articleID: article.articleID, key: SyncStatus.Key(statusKey), flag: flag)
|
||||
}
|
||||
|
||||
try? await database.insertStatuses(syncStatuses)
|
||||
try? await database.insertStatuses(Set(syncStatuses))
|
||||
|
||||
if let count = try? await database.selectPendingCount(), count > 100 {
|
||||
try? await sendArticleStatus(for: account)
|
||||
|
@ -729,11 +729,12 @@ private extension FeedbinAccountDelegate {
|
|||
let articleIDGroups = articleIDs.chunked(into: 1000)
|
||||
for articleIDGroup in articleIDGroups {
|
||||
|
||||
let articleIDsGroupAsString = Set(articleIDGroup.map { String($0) })
|
||||
do {
|
||||
try await apiCall(articleIDGroup)
|
||||
try? await database.deleteSelectedForProcessing(articleIDGroup.map { String($0) } )
|
||||
try? await database.deleteSelectedForProcessing(articleIDsGroupAsString)
|
||||
} catch {
|
||||
try? await database.resetSelectedForProcessing(articleIDGroup.map { String($0) } )
|
||||
try? await database.resetSelectedForProcessing(articleIDsGroupAsString)
|
||||
localError = error
|
||||
os_log(.error, log: self.log, "Article status sync call failed: %@.", error.localizedDescription)
|
||||
}
|
||||
|
|
|
@ -357,7 +357,7 @@ final class FeedlyAccountDelegate: AccountDelegate {
|
|||
return SyncStatus(articleID: article.articleID, key: SyncStatus.Key(statusKey), flag: flag)
|
||||
}
|
||||
|
||||
try? await syncDatabase.insertStatuses(syncStatuses)
|
||||
try? await syncDatabase.insertStatuses(Set(syncStatuses))
|
||||
|
||||
if let count = try? await syncDatabase.selectPendingCount(), count > 100 {
|
||||
try? await sendArticleStatus(for: account)
|
||||
|
@ -563,9 +563,9 @@ final class FeedlyAccountDelegate: AccountDelegate {
|
|||
|
||||
do {
|
||||
try await caller.mark(articleIDs, as: statusAction.action)
|
||||
try? await syncDatabase.deleteSelectedForProcessing(Array(articleIDs))
|
||||
try? await syncDatabase.deleteSelectedForProcessing(articleIDs)
|
||||
} catch {
|
||||
try? await syncDatabase.resetSelectedForProcessing(Array(articleIDs))
|
||||
try? await syncDatabase.resetSelectedForProcessing(articleIDs)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
|
|
@ -19,29 +19,22 @@ import CommonErrors
|
|||
|
||||
extension NewsBlurAccountDelegate {
|
||||
|
||||
func refreshFeeds(for account: Account, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
os_log(.debug, log: log, "Refreshing feeds...")
|
||||
func refreshFeeds(for account: Account) async throws {
|
||||
|
||||
caller.retrieveFeeds { result in
|
||||
os_log(.debug, log: log, "Refreshing feeds…")
|
||||
|
||||
MainActor.assumeIsolated {
|
||||
switch result {
|
||||
case .success((let feeds, let folders)):
|
||||
BatchUpdate.shared.perform {
|
||||
self.syncFolders(account, folders)
|
||||
self.syncFeeds(account, feeds)
|
||||
self.syncFeedFolderRelationship(account, folders)
|
||||
}
|
||||
completion(.success(()))
|
||||
case .failure(let error):
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
let (feeds, folders) = try await caller.retrieveFeeds()
|
||||
|
||||
BatchUpdate.shared.perform {
|
||||
self.syncFolders(account, folders)
|
||||
self.syncFeeds(account, feeds)
|
||||
self.syncFeedFolderRelationship(account, folders)
|
||||
}
|
||||
}
|
||||
|
||||
func syncFolders(_ account: Account, _ folders: [NewsBlurFolder]?) {
|
||||
guard let folders = folders else { return }
|
||||
|
||||
guard let folders else { return }
|
||||
assert(Thread.isMainThread)
|
||||
|
||||
os_log(.debug, log: log, "Syncing folders with %ld folders.", folders.count)
|
||||
|
@ -79,7 +72,7 @@ extension NewsBlurAccountDelegate {
|
|||
}
|
||||
|
||||
func syncFeeds(_ account: Account, _ feeds: [NewsBlurFeed]?) {
|
||||
guard let feeds = feeds else { return }
|
||||
guard let feeds else { return }
|
||||
assert(Thread.isMainThread)
|
||||
|
||||
os_log(.debug, log: log, "Syncing feeds with %ld feeds.", feeds.count)
|
||||
|
@ -130,7 +123,8 @@ extension NewsBlurAccountDelegate {
|
|||
}
|
||||
|
||||
func syncFeedFolderRelationship(_ account: Account, _ folders: [NewsBlurFolder]?) {
|
||||
guard let folders = folders else { return }
|
||||
|
||||
guard let folders else { return }
|
||||
assert(Thread.isMainThread)
|
||||
|
||||
os_log(.debug, log: log, "Syncing folders with %ld folders.", folders.count)
|
||||
|
@ -231,44 +225,23 @@ extension NewsBlurAccountDelegate {
|
|||
return d
|
||||
}
|
||||
|
||||
func refreshUnreadStories(for account: Account, hashes: [NewsBlurStoryHash]?, updateFetchDate: Date?, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
guard let hashes = hashes, !hashes.isEmpty else {
|
||||
func refreshUnreadStories(for account: Account, hashes: [NewsBlurStoryHash]?, updateFetchDate: Date?) async throws {
|
||||
|
||||
guard let 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))
|
||||
}
|
||||
}
|
||||
let (stories, date) = try await caller.retrieveStories(hashes: hashesToFetch)
|
||||
try await processStories(account: account, stories: stories)
|
||||
try await refreshUnreadStories(for: account, hashes: Array(hashes[numberOfStories...]), updateFetchDate: date)
|
||||
os_log(.debug, log: self.log, "Done refreshing stories.")
|
||||
}
|
||||
|
||||
func mapStoriesToParsedItems(stories: [NewsBlurStory]?) -> Set<ParsedItem> {
|
||||
|
@ -282,251 +255,170 @@ extension NewsBlurAccountDelegate {
|
|||
return Set(parsedItems)
|
||||
}
|
||||
|
||||
func sendStoryStatuses(_ statuses: [SyncStatus],
|
||||
throttle: Bool,
|
||||
apiCall: ([String], @escaping (Result<Void, Error>) -> Void) -> Void,
|
||||
completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
func sendStoryStatuses(_ statuses: Set<SyncStatus>, throttle: Bool, apiCall: (Set<String>) async throws -> Void) async throws {
|
||||
|
||||
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:
|
||||
Task {
|
||||
try? await 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)
|
||||
Task {
|
||||
try? await self.database.resetSelectedForProcessing(storyHashGroup.map { String($0) } )
|
||||
group.leave()
|
||||
}
|
||||
}
|
||||
|
||||
do {
|
||||
try await apiCall(Set(storyHashGroup))
|
||||
} catch {
|
||||
errorOccurred = true
|
||||
os_log(.error, log: self.log, "Story status sync call failed: %@.", error.localizedDescription)
|
||||
try? await syncDatabase.resetSelectedForProcessing(Set(storyHashGroup))
|
||||
}
|
||||
}
|
||||
|
||||
group.notify(queue: DispatchQueue.main) {
|
||||
if errorOccurred {
|
||||
completion(.failure(NewsBlurError.unknown))
|
||||
} else {
|
||||
completion(.success(()))
|
||||
}
|
||||
if errorOccurred {
|
||||
throw NewsBlurError.unknown
|
||||
}
|
||||
}
|
||||
|
||||
func syncStoryReadState(account: Account, hashes: [NewsBlurStoryHash]?, completion: @escaping (() -> Void)) {
|
||||
func syncStoryReadState(account: Account, hashes: Set<NewsBlurStoryHash>?) async {
|
||||
|
||||
guard let hashes else {
|
||||
completion()
|
||||
return
|
||||
}
|
||||
|
||||
Task { @MainActor in
|
||||
do {
|
||||
let pendingArticleIDs = (try await self.database.selectPendingReadStatusArticleIDs()) ?? Set<String>()
|
||||
do {
|
||||
let pendingArticleIDs = (try await syncDatabase.selectPendingReadStatusArticleIDs()) ?? Set<String>()
|
||||
|
||||
let newsBlurUnreadStoryHashes = Set(hashes.map { $0.hash } )
|
||||
let updatableNewsBlurUnreadStoryHashes = newsBlurUnreadStoryHashes.subtracting(pendingArticleIDs)
|
||||
let newsBlurUnreadStoryHashes = Set(hashes.map { $0.hash } )
|
||||
let updatableNewsBlurUnreadStoryHashes = newsBlurUnreadStoryHashes.subtracting(pendingArticleIDs)
|
||||
|
||||
guard let currentUnreadArticleIDs = try await account.fetchUnreadArticleIDs() else {
|
||||
completion()
|
||||
return
|
||||
}
|
||||
|
||||
// Mark articles as unread
|
||||
let deltaUnreadArticleIDs = updatableNewsBlurUnreadStoryHashes.subtracting(currentUnreadArticleIDs)
|
||||
try? await account.markAsUnread(deltaUnreadArticleIDs)
|
||||
|
||||
// Mark articles as read
|
||||
let deltaReadArticleIDs = currentUnreadArticleIDs.subtracting(updatableNewsBlurUnreadStoryHashes)
|
||||
try? await account.markAsRead(deltaReadArticleIDs)
|
||||
|
||||
completion()
|
||||
|
||||
} catch {
|
||||
os_log(.error, log: self.log, "Sync Story Read Status failed: %@.", error.localizedDescription)
|
||||
guard let currentUnreadArticleIDs = try await account.fetchUnreadArticleIDs() else {
|
||||
return
|
||||
}
|
||||
|
||||
// Mark articles as unread
|
||||
let deltaUnreadArticleIDs = updatableNewsBlurUnreadStoryHashes.subtracting(currentUnreadArticleIDs)
|
||||
try? await account.markAsUnread(deltaUnreadArticleIDs)
|
||||
|
||||
// Mark articles as read
|
||||
let deltaReadArticleIDs = currentUnreadArticleIDs.subtracting(updatableNewsBlurUnreadStoryHashes)
|
||||
try? await account.markAsRead(deltaReadArticleIDs)
|
||||
} catch {
|
||||
os_log(.error, log: self.log, "Sync Story Read Status failed: %@.", error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
func syncStoryStarredState(account: Account, hashes: [NewsBlurStoryHash]?, completion: @escaping (() -> Void)) {
|
||||
guard let hashes = hashes else {
|
||||
completion()
|
||||
func syncStoryStarredState(account: Account, hashes: Set<NewsBlurStoryHash>?) async {
|
||||
|
||||
guard let hashes else {
|
||||
return
|
||||
}
|
||||
|
||||
Task { @MainActor in
|
||||
do {
|
||||
let pendingArticleIDs = (try await syncDatabase.selectPendingStarredStatusArticleIDs()) ?? Set<String>()
|
||||
|
||||
do {
|
||||
let pendingArticleIDs = (try await self.database.selectPendingStarredStatusArticleIDs()) ?? Set<String>()
|
||||
let newsBlurStarredStoryHashes = Set(hashes.map { $0.hash } )
|
||||
let updatableNewsBlurUnreadStoryHashes = newsBlurStarredStoryHashes.subtracting(pendingArticleIDs)
|
||||
|
||||
let newsBlurStarredStoryHashes = Set(hashes.map { $0.hash } )
|
||||
let updatableNewsBlurUnreadStoryHashes = newsBlurStarredStoryHashes.subtracting(pendingArticleIDs)
|
||||
|
||||
guard let currentStarredArticleIDs = try await account.fetchStarredArticleIDs() else {
|
||||
completion()
|
||||
return
|
||||
}
|
||||
|
||||
// Mark articles as starred
|
||||
let deltaStarredArticleIDs = updatableNewsBlurUnreadStoryHashes.subtracting(currentStarredArticleIDs)
|
||||
try? await account.markAsStarred(deltaStarredArticleIDs)
|
||||
|
||||
// Mark articles as unstarred
|
||||
let deltaUnstarredArticleIDs = currentStarredArticleIDs.subtracting(updatableNewsBlurUnreadStoryHashes)
|
||||
try? await account.markAsUnstarred(deltaUnstarredArticleIDs)
|
||||
|
||||
completion()
|
||||
|
||||
} catch {
|
||||
os_log(.error, log: self.log, "Sync Story Starred Status failed: %@.", error.localizedDescription)
|
||||
guard let currentStarredArticleIDs = try await account.fetchStarredArticleIDs() else {
|
||||
return
|
||||
}
|
||||
|
||||
// Mark articles as starred
|
||||
let deltaStarredArticleIDs = updatableNewsBlurUnreadStoryHashes.subtracting(currentStarredArticleIDs)
|
||||
try? await account.markAsStarred(deltaStarredArticleIDs)
|
||||
|
||||
// Mark articles as unstarred
|
||||
let deltaUnstarredArticleIDs = currentStarredArticleIDs.subtracting(updatableNewsBlurUnreadStoryHashes)
|
||||
try? await account.markAsUnstarred(deltaUnstarredArticleIDs)
|
||||
} catch {
|
||||
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<Feed, Error>) -> Void) {
|
||||
guard let feed = feed else {
|
||||
completion(.failure(NewsBlurError.invalidParameter))
|
||||
func createFeed(account: Account, newsBlurFeed: NewsBlurFeed, name: String?, container: Container) async throws -> Feed {
|
||||
|
||||
let feed = account.createFeed(with: newsBlurFeed.name, url: newsBlurFeed.feedURL, feedID: String(newsBlurFeed.feedID), homePageURL: newsBlurFeed.homePageURL)
|
||||
feed.externalID = String(newsBlurFeed.feedID)
|
||||
feed.faviconURL = newsBlurFeed.faviconURL
|
||||
|
||||
try await account.addFeed(feed, to: container)
|
||||
if let name {
|
||||
try await renameFeed(for: account, with: feed, to: name)
|
||||
}
|
||||
try await initialFeedDownload(account: account, feed: feed)
|
||||
return feed
|
||||
}
|
||||
|
||||
func downloadFeed(account: Account, feed: Feed, page: Int) async throws {
|
||||
|
||||
refreshProgress.addTask()
|
||||
defer {
|
||||
refreshProgress.completeTask()
|
||||
}
|
||||
|
||||
let (stories, _) = try await caller.retrieveStories(feedID: feed.feedID, page: page)
|
||||
refreshProgress.completeTask()
|
||||
|
||||
guard let stories, stories.count > 0 else {
|
||||
return
|
||||
}
|
||||
|
||||
Task { @MainActor in
|
||||
let feed = account.createFeed(with: feed.name, url: feed.feedURL, feedID: String(feed.feedID), homePageURL: feed.homePageURL)
|
||||
feed.externalID = String(feed.feedID)
|
||||
feed.faviconURL = feed.faviconURL
|
||||
let since: Date? = Calendar.current.date(byAdding: .month, value: -3, to: Date())
|
||||
|
||||
do {
|
||||
try await account.addFeed(feed, to: container)
|
||||
if let name {
|
||||
try await self.renameFeed(for: account, with: feed, to: name)
|
||||
}
|
||||
self.initialFeedDownload(account: account, feed: feed, completion: completion)
|
||||
} catch {
|
||||
completion(.failure(error))
|
||||
}
|
||||
let hasStories = try await processStories(account: account, stories: stories, since: since)
|
||||
if hasStories {
|
||||
try await downloadFeed(account: account, feed: feed, page: page + 1)
|
||||
}
|
||||
}
|
||||
|
||||
func downloadFeed(account: Account, feed: Feed, page: Int, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
refreshProgress.addToNumberOfTasksAndRemaining(1)
|
||||
func initialFeedDownload(account: Account, feed: Feed) async throws {
|
||||
|
||||
caller.retrieveStories(feedID: feed.feedID, 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))
|
||||
}
|
||||
refreshProgress.addTask()
|
||||
defer {
|
||||
refreshProgress.completeTask()
|
||||
}
|
||||
}
|
||||
|
||||
func initialFeedDownload(account: Account, feed: Feed, completion: @escaping (Result<Feed, Error>) -> Void) {
|
||||
refreshProgress.addToNumberOfTasksAndRemaining(1)
|
||||
|
||||
// Download the initial articles
|
||||
downloadFeed(account: account, feed: feed, page: 1) { result in
|
||||
|
||||
Task { @MainActor in
|
||||
do {
|
||||
try await self.refreshArticleStatus(for: account)
|
||||
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))
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
try await downloadFeed(account: account, feed: feed, page: 1)
|
||||
try await refreshArticleStatus(for: account)
|
||||
try await refreshMissingStories(for: account)
|
||||
}
|
||||
|
||||
func deleteFeed(for account: Account, with feed: Feed, from container: Container?, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
func deleteFeed(for account: Account, with feed: Feed, from container: Container?) async throws {
|
||||
|
||||
// This error should never happen
|
||||
guard let feedID = feed.externalID else {
|
||||
completion(.failure(NewsBlurError.invalidParameter))
|
||||
return
|
||||
throw NewsBlurError.invalidParameter
|
||||
}
|
||||
|
||||
refreshProgress.addToNumberOfTasksAndRemaining(1)
|
||||
refreshProgress.addTask()
|
||||
defer {
|
||||
refreshProgress.completeTask()
|
||||
}
|
||||
|
||||
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.feedID
|
||||
do {
|
||||
try await caller.deleteFeed(feedID: feedID, folder: folderName)
|
||||
|
||||
if folderName == nil {
|
||||
account.removeFeed(feed)
|
||||
}
|
||||
if folderName == nil {
|
||||
account.removeFeed(feed)
|
||||
}
|
||||
|
||||
if let folders = account.folders {
|
||||
for folder in folders where folderName != nil && folder.name == folderName {
|
||||
folder.removeFeed(feed)
|
||||
}
|
||||
}
|
||||
|
||||
if account.existingFeed(withFeedID: feedID) != nil {
|
||||
account.clearFeedMetadata(feed)
|
||||
}
|
||||
|
||||
completion(.success(()))
|
||||
}
|
||||
|
||||
case .failure(let error):
|
||||
DispatchQueue.main.async {
|
||||
let wrappedError = AccountError.wrappedError(error: error, account: account)
|
||||
completion(.failure(wrappedError))
|
||||
if let folders = account.folders {
|
||||
for folder in folders where folderName != nil && folder.name == folderName {
|
||||
folder.removeFeed(feed)
|
||||
}
|
||||
}
|
||||
|
||||
if account.existingFeed(withFeedID: feed.feedID) != nil {
|
||||
account.clearFeedMetadata(feed)
|
||||
}
|
||||
|
||||
} catch {
|
||||
throw AccountError.wrappedError(error: error, account: account)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -33,7 +33,7 @@ final class NewsBlurAccountDelegate: AccountDelegate {
|
|||
|
||||
let caller: NewsBlurAPICaller
|
||||
let log = OSLog(subsystem: Bundle.main.bundleIdentifier!, category: "NewsBlur")
|
||||
let database: SyncDatabase
|
||||
let syncDatabase: SyncDatabase
|
||||
|
||||
init(dataFolder: String, transport: Transport?) {
|
||||
if let transport = transport {
|
||||
|
@ -53,320 +53,164 @@ final class NewsBlurAccountDelegate: AccountDelegate {
|
|||
caller = NewsBlurAPICaller(transport: session)
|
||||
}
|
||||
|
||||
database = SyncDatabase(databasePath: dataFolder.appending("/DB.sqlite3"))
|
||||
syncDatabase = SyncDatabase(databasePath: dataFolder.appending("/DB.sqlite3"))
|
||||
}
|
||||
|
||||
func receiveRemoteNotification(for account: Account, userInfo: [AnyHashable : Any]) async {
|
||||
}
|
||||
|
||||
func refreshAll(for account: Account) async throws {
|
||||
|
||||
refreshProgress.addToNumberOfTasksAndRemaining(4)
|
||||
|
||||
try await withCheckedThrowingContinuation { continuation in
|
||||
try await refreshFeeds(for: account)
|
||||
refreshProgress.completeTask()
|
||||
|
||||
self.refreshAll(for: account) { result in
|
||||
switch result {
|
||||
case .success:
|
||||
continuation.resume()
|
||||
case .failure(let error):
|
||||
continuation.resume(throwing: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
try await sendArticleStatus(for: account)
|
||||
refreshProgress.completeTask()
|
||||
|
||||
private func refreshAll(for account: Account, completion: @escaping (Result<Void, Error>) -> ()) {
|
||||
self.refreshProgress.addToNumberOfTasksAndRemaining(4)
|
||||
try await refreshArticleStatus(for: account)
|
||||
refreshProgress.completeTask()
|
||||
|
||||
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.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))
|
||||
}
|
||||
}
|
||||
try await refreshMissingStories(for: account)
|
||||
refreshProgress.completeTask()
|
||||
}
|
||||
|
||||
func syncArticleStatus(for account: Account) async throws {
|
||||
|
||||
try await withCheckedThrowingContinuation { continuation in
|
||||
sendArticleStatus(for: account) { result in
|
||||
switch result {
|
||||
case .success:
|
||||
self.refreshArticleStatus(for: account) { result in
|
||||
switch result {
|
||||
case .success:
|
||||
continuation.resume()
|
||||
case .failure(let error):
|
||||
continuation.resume(throwing: error)
|
||||
}
|
||||
}
|
||||
case .failure(let error):
|
||||
continuation.resume(throwing: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
try await sendArticleStatus(for: account)
|
||||
try await refreshArticleStatus(for: account)
|
||||
}
|
||||
|
||||
|
||||
public func sendArticleStatus(for account: Account) async throws {
|
||||
|
||||
try await withCheckedThrowingContinuation { continuation in
|
||||
os_log(.debug, log: log, "Sending story statuses…")
|
||||
|
||||
self.sendArticleStatus(for: account) { result in
|
||||
switch result {
|
||||
case .success:
|
||||
continuation.resume()
|
||||
case .failure(let error):
|
||||
continuation.resume(throwing: error)
|
||||
}
|
||||
}
|
||||
let syncStatuses = (try await self.syncDatabase.selectForProcessing()) ?? Set<SyncStatus>()
|
||||
|
||||
let createUnreadStatuses = syncStatuses.filter {
|
||||
$0.key == SyncStatus.Key.read && $0.flag == false
|
||||
}
|
||||
let deleteUnreadStatuses = syncStatuses.filter {
|
||||
$0.key == SyncStatus.Key.read && $0.flag == true
|
||||
}
|
||||
let createStarredStatuses = syncStatuses.filter {
|
||||
$0.key == SyncStatus.Key.starred && $0.flag == true
|
||||
}
|
||||
let deleteStarredStatuses = syncStatuses.filter {
|
||||
$0.key == SyncStatus.Key.starred && $0.flag == false
|
||||
}
|
||||
}
|
||||
|
||||
private func sendArticleStatus(for account: Account, completion: @escaping (Result<Void, Error>) -> ()) {
|
||||
os_log(.debug, log: log, "Sending story statuses...")
|
||||
|
||||
Task { @MainActor in
|
||||
var errorOccurred = false
|
||||
|
||||
do {
|
||||
let syncStatuses = (try await self.database.selectForProcessing()) ?? Set<SyncStatus>()
|
||||
do {
|
||||
try await sendStoryStatuses(createUnreadStatuses, throttle: true, apiCall: caller.markAsUnread)
|
||||
} catch {
|
||||
errorOccurred = true
|
||||
}
|
||||
|
||||
@MainActor func processStatuses(_ syncStatuses: [SyncStatus]) {
|
||||
let createUnreadStatuses = syncStatuses.filter {
|
||||
$0.key == SyncStatus.Key.read && $0.flag == false
|
||||
}
|
||||
let deleteUnreadStatuses = syncStatuses.filter {
|
||||
$0.key == SyncStatus.Key.read && $0.flag == true
|
||||
}
|
||||
let createStarredStatuses = syncStatuses.filter {
|
||||
$0.key == SyncStatus.Key.starred && $0.flag == true
|
||||
}
|
||||
let deleteStarredStatuses = syncStatuses.filter {
|
||||
$0.key == SyncStatus.Key.starred && $0.flag == false
|
||||
}
|
||||
do {
|
||||
try await sendStoryStatuses(deleteUnreadStatuses, throttle: false, apiCall: caller.markAsRead)
|
||||
} catch {
|
||||
errorOccurred = true
|
||||
}
|
||||
|
||||
let group = DispatchGroup()
|
||||
var errorOccurred = false
|
||||
do {
|
||||
try await sendStoryStatuses(createStarredStatuses, throttle: true, apiCall: caller.star)
|
||||
} catch {
|
||||
errorOccurred = true
|
||||
}
|
||||
|
||||
group.enter()
|
||||
self.sendStoryStatuses(createUnreadStatuses, throttle: true, apiCall: self.caller.markAsUnread) { result in
|
||||
group.leave()
|
||||
if case .failure = result {
|
||||
errorOccurred = true
|
||||
}
|
||||
}
|
||||
do {
|
||||
try await sendStoryStatuses(deleteStarredStatuses, throttle: true, apiCall: caller.unstar)
|
||||
} catch {
|
||||
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(()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
processStatuses(Array(syncStatuses))
|
||||
} catch {
|
||||
completion(.failure(error))
|
||||
}
|
||||
os_log(.debug, log: self.log, "Done sending article statuses.")
|
||||
if errorOccurred {
|
||||
throw NewsBlurError.unknown
|
||||
}
|
||||
}
|
||||
|
||||
func refreshArticleStatus(for account: Account) async throws {
|
||||
|
||||
try await withCheckedThrowingContinuation { continuation in
|
||||
self.refreshArticleStatus(for: account) { result in
|
||||
switch result {
|
||||
case .success:
|
||||
continuation.resume()
|
||||
case .failure(let error):
|
||||
continuation.resume(throwing: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
os_log(.debug, log: log, "Refreshing story statuses…")
|
||||
|
||||
private 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()
|
||||
}
|
||||
do {
|
||||
let storyHashes = try await caller.retrieveUnreadStoryHashes()
|
||||
await syncStoryReadState(account: account, hashes: storyHashes)
|
||||
} catch {
|
||||
errorOccurred = true
|
||||
os_log(.info, log: self.log, "Retrieving unread stories failed: %@.", error.localizedDescription)
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
do {
|
||||
let storyHashes = try await caller.retrieveStarredStoryHashes()
|
||||
await syncStoryStarredState(account: account, hashes: storyHashes)
|
||||
} catch {
|
||||
errorOccurred = true
|
||||
os_log(.info, log: self.log, "Retrieving starred stories failed: %@.", error.localizedDescription)
|
||||
}
|
||||
|
||||
group.notify(queue: DispatchQueue.main) {
|
||||
os_log(.debug, log: self.log, "Done refreshing article statuses.")
|
||||
if errorOccurred {
|
||||
completion(.failure(NewsBlurError.unknown))
|
||||
} else {
|
||||
completion(.success(()))
|
||||
}
|
||||
os_log(.debug, log: self.log, "Done refreshing article statuses.")
|
||||
if errorOccurred {
|
||||
throw NewsBlurError.unknown
|
||||
}
|
||||
}
|
||||
|
||||
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...")
|
||||
func refreshStories(for account: Account) async throws {
|
||||
|
||||
caller.retrieveUnreadStoryHashes { result in
|
||||
switch result {
|
||||
case .success(let storyHashes):
|
||||
os_log(.debug, log: log, "Refreshing stories…")
|
||||
os_log(.debug, log: log, "Refreshing unread stories…")
|
||||
|
||||
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))
|
||||
}
|
||||
let storyHashes = try await caller.retrieveUnreadStoryHashes()
|
||||
if let count = storyHashes?.count, count > 0 {
|
||||
refreshProgress.addToNumberOfTasksAndRemaining((count - 1) / 100 + 1)
|
||||
}
|
||||
|
||||
let storyHashesArray: [NewsBlurStoryHash] = {
|
||||
if let storyHashes {
|
||||
return Array(storyHashes)
|
||||
}
|
||||
return [NewsBlurStoryHash]()
|
||||
}()
|
||||
try await refreshUnreadStories(for: account, hashes: storyHashesArray, updateFetchDate: nil)
|
||||
}
|
||||
|
||||
func refreshMissingStories(for account: Account, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
os_log(.debug, log: log, "Refreshing missing stories...")
|
||||
func refreshMissingStories(for account: Account) async throws {
|
||||
|
||||
Task { @MainActor in
|
||||
os_log(.debug, log: log, "Refreshing missing stories…")
|
||||
|
||||
let fetchedArticleIDs = try await account.fetchArticleIDsForStatusesWithoutArticlesNewerThanCutoffDate() ?? Set<String>()
|
||||
|
||||
var errorOccurred = false
|
||||
|
||||
let storyHashes = Array(fetchedArticleIDs).map {
|
||||
NewsBlurStoryHash(hash: $0, timestamp: Date())
|
||||
}
|
||||
let chunkedStoryHashes = storyHashes.chunked(into: 100)
|
||||
|
||||
for chunk in chunkedStoryHashes {
|
||||
do {
|
||||
let fetchedArticleIDs = try await account.fetchArticleIDsForStatusesWithoutArticlesNewerThanCutoffDate() ?? Set<String>()
|
||||
|
||||
let group = DispatchGroup()
|
||||
var errorOccurred = false
|
||||
|
||||
let storyHashes = Array(fetchedArticleIDs).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(()))
|
||||
}
|
||||
}
|
||||
|
||||
let (stories, _) = try await caller.retrieveStories(hashes: chunk)
|
||||
try await processStories(account: account, stories: stories)
|
||||
} catch {
|
||||
self.refreshProgress.completeTask()
|
||||
completion(.failure(error))
|
||||
errorOccurred = true
|
||||
os_log(.error, log: self.log, "Refresh missing stories failed: %@.", error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
os_log(.debug, log: self.log, "Done refreshing missing stories.")
|
||||
if errorOccurred {
|
||||
throw NewsBlurError.unknown
|
||||
}
|
||||
}
|
||||
|
||||
func processStories(account: Account, stories: [NewsBlurStory]?, since: Date? = nil, completion: @escaping (Result<Bool, DatabaseError>) -> Void) {
|
||||
@discardableResult
|
||||
func processStories(account: Account, stories: [NewsBlurStory]?, since: Date? = nil) async throws -> Bool {
|
||||
|
||||
let parsedItems = mapStoriesToParsedItems(stories: stories).filter {
|
||||
guard let datePublished = $0.datePublished, let since = since else {
|
||||
|
@ -379,14 +223,8 @@ final class NewsBlurAccountDelegate: AccountDelegate {
|
|||
Set($0)
|
||||
}
|
||||
|
||||
Task { @MainActor in
|
||||
do {
|
||||
try await account.update(feedIDsAndItems: feedIDsAndItems, defaultRead: true)
|
||||
completion(.success(!feedIDsAndItems.isEmpty))
|
||||
} catch {
|
||||
completion(.failure(.suspended))
|
||||
}
|
||||
}
|
||||
try await account.update(feedIDsAndItems: feedIDsAndItems, defaultRead: true)
|
||||
return !feedIDsAndItems.isEmpty
|
||||
}
|
||||
|
||||
func importOPML(for account: Account, opmlFile: URL) async throws {
|
||||
|
@ -394,97 +232,44 @@ final class NewsBlurAccountDelegate: AccountDelegate {
|
|||
|
||||
func createFolder(for account: Account, name: String) async throws -> Folder {
|
||||
|
||||
try await withCheckedThrowingContinuation { continuation in
|
||||
refreshProgress.addTask()
|
||||
|
||||
self.createFolder(for: account, name: name) { result in
|
||||
switch result {
|
||||
case .success(let folder):
|
||||
continuation.resume(returning: folder)
|
||||
case .failure(let error):
|
||||
continuation.resume(throwing: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
try await caller.addFolder(named: name)
|
||||
refreshProgress.completeTask()
|
||||
|
||||
private func createFolder(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))
|
||||
}
|
||||
if let folder = account.ensureFolder(with: name) {
|
||||
return folder
|
||||
} else {
|
||||
throw NewsBlurError.invalidParameter
|
||||
}
|
||||
}
|
||||
|
||||
func renameFolder(for account: Account, with folder: Folder, to name: String) async throws {
|
||||
|
||||
try await withCheckedThrowingContinuation { continuation in
|
||||
|
||||
self.renameFolder(for: account, with: folder, to: name) { result in
|
||||
switch result {
|
||||
case .success:
|
||||
continuation.resume()
|
||||
case .failure(let error):
|
||||
continuation.resume(throwing: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private 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
|
||||
throw NewsBlurError.invalidParameter
|
||||
}
|
||||
|
||||
refreshProgress.addToNumberOfTasksAndRemaining(1)
|
||||
refreshProgress.addTask()
|
||||
defer {
|
||||
refreshProgress.completeTask()
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
do {
|
||||
try await caller.renameFolder(with: folderToRename, to: name)
|
||||
folder.name = name
|
||||
} catch {
|
||||
folder.name = nameBefore
|
||||
throw error
|
||||
}
|
||||
|
||||
folder.name = name
|
||||
}
|
||||
|
||||
func removeFolder(for account: Account, with folder: Folder) async throws {
|
||||
|
||||
try await withCheckedThrowingContinuation { continuation in
|
||||
|
||||
self.removeFolder(for: account, with: folder) { result in
|
||||
switch result {
|
||||
case .success:
|
||||
continuation.resume()
|
||||
case .failure(let error):
|
||||
continuation.resume(throwing: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func removeFolder(for account: Account, with folder: Folder, completion: @escaping (Result<Void, Error>) -> ()) {
|
||||
guard let folderToRemove = folder.name else {
|
||||
completion(.failure(NewsBlurError.invalidParameter))
|
||||
return
|
||||
throw NewsBlurError.invalidParameter
|
||||
}
|
||||
|
||||
var feedIDs: [String] = []
|
||||
|
@ -496,246 +281,105 @@ final class NewsBlurAccountDelegate: AccountDelegate {
|
|||
}
|
||||
}
|
||||
|
||||
refreshProgress.addToNumberOfTasksAndRemaining(1)
|
||||
|
||||
caller.removeFolder(named: folderToRemove, feedIDs: feedIDs) { result in
|
||||
self.refreshProgress.completeTask()
|
||||
|
||||
switch result {
|
||||
case .success:
|
||||
account.removeFolder(folder: folder)
|
||||
completion(.success(()))
|
||||
case .failure(let error):
|
||||
completion(.failure(error))
|
||||
}
|
||||
refreshProgress.addTask()
|
||||
defer {
|
||||
refreshProgress.completeTask()
|
||||
}
|
||||
|
||||
try await caller.removeFolder(named: folderToRemove, feedIDs: feedIDs)
|
||||
account.removeFolder(folder: folder)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func createFeed(for account: Account, url: String, name: String?, container: Container, validateFeed: Bool) async throws -> Feed {
|
||||
|
||||
try await withCheckedThrowingContinuation { continuation in
|
||||
self.createFeed(for: account, url: url, name: name, container: container, validateFeed: validateFeed) { result in
|
||||
switch result {
|
||||
case .success(let feed):
|
||||
continuation.resume(returning: feed)
|
||||
case .failure(let error):
|
||||
continuation.resume(throwing: error)
|
||||
}
|
||||
}
|
||||
refreshProgress.addTask()
|
||||
defer {
|
||||
refreshProgress.completeTask()
|
||||
}
|
||||
}
|
||||
|
||||
private func createFeed(for account: Account, url: String, name: String?, container: Container, validateFeed: Bool, completion: @escaping (Result<Feed, 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))
|
||||
}
|
||||
do {
|
||||
guard let newsBlurFeed = try await caller.addURL(url, folder: folderName) else {
|
||||
throw NewsBlurError.unknown
|
||||
}
|
||||
let feed = try await createFeed(account: account, newsBlurFeed: newsBlurFeed, name: name, container: container)
|
||||
return feed
|
||||
} catch {
|
||||
throw AccountError.wrappedError(error: error, account: account)
|
||||
}
|
||||
}
|
||||
|
||||
func renameFeed(for account: Account, with feed: Feed, to name: String) async throws {
|
||||
|
||||
try await withCheckedThrowingContinuation { continuation in
|
||||
|
||||
self.renameFeed(for: account, with: feed, to: name) { result in
|
||||
switch result {
|
||||
case .success:
|
||||
continuation.resume()
|
||||
case .failure(let error):
|
||||
continuation.resume(throwing: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func renameFeed(for account: Account, with feed: Feed, to name: String, completion: @escaping (Result<Void, Error>) -> ()) {
|
||||
guard let feedID = feed.externalID else {
|
||||
completion(.failure(NewsBlurError.invalidParameter))
|
||||
return
|
||||
throw NewsBlurError.invalidParameter
|
||||
}
|
||||
|
||||
refreshProgress.addToNumberOfTasksAndRemaining(1)
|
||||
refreshProgress.addTask()
|
||||
defer {
|
||||
refreshProgress.completeTask()
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
}
|
||||
do {
|
||||
try await caller.renameFeed(feedID: feedID, newName: name)
|
||||
feed.editedName = name
|
||||
} catch {
|
||||
throw AccountError.wrappedError(error: error, account: account)
|
||||
}
|
||||
}
|
||||
|
||||
func addFeed(for account: Account, with feed: Feed, to container: any Container) async throws {
|
||||
|
||||
try await withCheckedThrowingContinuation { continuation in
|
||||
|
||||
self.addFeed(for: account, with: feed, to: container) { result in
|
||||
switch result {
|
||||
case .success:
|
||||
continuation.resume()
|
||||
case .failure(let error):
|
||||
continuation.resume(throwing: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func addFeed(for account: Account, with feed: Feed, 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.addFeed(feed)
|
||||
}
|
||||
completion(.success(()))
|
||||
}
|
||||
|
||||
if let account = container as? Account {
|
||||
account.addFeed(feed)
|
||||
return
|
||||
}
|
||||
|
||||
guard let folder = container as? Folder else {
|
||||
return
|
||||
}
|
||||
let folderName = folder.name ?? ""
|
||||
saveFolderRelationship(for: feed, withFolderName: folderName, id: folderName)
|
||||
folder.addFeed(feed)
|
||||
|
||||
completion(.success(()))
|
||||
}
|
||||
|
||||
func removeFeed(for account: Account, with feed: Feed, from container: any Container) async throws {
|
||||
|
||||
try await withCheckedThrowingContinuation { continuation in
|
||||
|
||||
self.removeFeed(for: account, with: feed, from: container) { result in
|
||||
switch result {
|
||||
case .success:
|
||||
continuation.resume()
|
||||
case .failure(let error):
|
||||
continuation.resume(throwing: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func removeFeed(for account: Account, with feed: Feed, from container: Container, completion: @escaping (Result<Void, Error>) -> ()) {
|
||||
deleteFeed(for: account, with: feed, from: container, completion: completion)
|
||||
try await deleteFeed(for: account, with: feed, from: container)
|
||||
}
|
||||
|
||||
func moveFeed(for account: Account, with feed: Feed, from: Container, to: Container) async throws {
|
||||
|
||||
try await withCheckedThrowingContinuation { continuation in
|
||||
self.moveFeed(for: account, with: feed, from: from, to: to) { result in
|
||||
switch result {
|
||||
case .success:
|
||||
continuation.resume()
|
||||
case .failure(let error):
|
||||
continuation.resume(throwing: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func moveFeed(for account: Account, with feed: Feed, from: Container, to: Container, completion: @escaping (Result<Void, Error>) -> ()) {
|
||||
guard let feedID = feed.externalID else {
|
||||
completion(.failure(NewsBlurError.invalidParameter))
|
||||
return
|
||||
throw NewsBlurError.invalidParameter
|
||||
}
|
||||
|
||||
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.removeFeed(feed)
|
||||
to.addFeed(feed)
|
||||
|
||||
completion(.success(()))
|
||||
case .failure(let error):
|
||||
completion(.failure(error))
|
||||
}
|
||||
refreshProgress.addTask()
|
||||
defer {
|
||||
refreshProgress.completeTask()
|
||||
}
|
||||
|
||||
|
||||
try await caller.moveFeed( feedID: feedID, from: (from as? Folder)?.name, to: (to as? Folder)?.name)
|
||||
from.removeFeed(feed)
|
||||
to.addFeed(feed)
|
||||
}
|
||||
|
||||
func restoreFeed(for account: Account, feed: Feed, container: any Container) async throws {
|
||||
|
||||
try await withCheckedThrowingContinuation { continuation in
|
||||
|
||||
self.restoreFeed(for: account, feed: feed, container: container) { result in
|
||||
switch result {
|
||||
case .success:
|
||||
continuation.resume()
|
||||
case .failure(let error):
|
||||
continuation.resume(throwing: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func restoreFeed(for account: Account, feed: Feed, container: Container, completion: @escaping (Result<Void, Error>) -> ()) {
|
||||
if let existingFeed = account.existingFeed(withURL: feed.url) {
|
||||
Task { @MainActor in
|
||||
|
||||
do {
|
||||
try await account.addFeed(existingFeed, to: container)
|
||||
completion(.success(()))
|
||||
} catch {
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
return try await account.addFeed(existingFeed, to: container)
|
||||
} else {
|
||||
createFeed(for: account, url: feed.url, name: feed.editedName, container: container, validateFeed: true) { result in
|
||||
switch result {
|
||||
case .success:
|
||||
completion(.success(()))
|
||||
case .failure(let error):
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
try await createFeed(for: account, url: feed.url, name: feed.editedName, container: container, validateFeed: true)
|
||||
}
|
||||
}
|
||||
|
||||
func restoreFolder(for account: Account, folder: Folder) async throws {
|
||||
|
||||
try await withCheckedThrowingContinuation { continuation in
|
||||
self.restoreFolder(for: account, folder: folder) { result in
|
||||
switch result {
|
||||
case .success:
|
||||
continuation.resume()
|
||||
case .failure(let error):
|
||||
continuation.resume(throwing: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func restoreFolder(for account: Account, folder: Folder, completion: @escaping (Result<Void, Error>) -> ()) {
|
||||
guard let folderName = folder.name else {
|
||||
completion(.failure(NewsBlurError.invalidParameter))
|
||||
return
|
||||
throw NewsBlurError.invalidParameter
|
||||
}
|
||||
|
||||
var feedsToRestore: [Feed] = []
|
||||
|
@ -744,71 +388,33 @@ final class NewsBlurAccountDelegate: AccountDelegate {
|
|||
folder.topLevelFeeds.remove(feed)
|
||||
}
|
||||
|
||||
let group = DispatchGroup()
|
||||
|
||||
group.enter()
|
||||
createFolder(for: account, name: folderName) { result in
|
||||
group.leave()
|
||||
switch result {
|
||||
case .success(let folder):
|
||||
for feed in feedsToRestore {
|
||||
group.enter()
|
||||
self.restoreFeed(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)
|
||||
}
|
||||
}
|
||||
do {
|
||||
let folder = try await createFolder(for: account, name: folderName)
|
||||
for feed in feedsToRestore {
|
||||
do {
|
||||
try await restoreFeed(for: account, feed: feed, container: folder)
|
||||
} catch {
|
||||
os_log(.error, log: self.log, "Restore folder feed error: %@.", error.localizedDescription)
|
||||
throw error
|
||||
}
|
||||
case .failure(let error):
|
||||
os_log(.error, log: self.log, "Restore folder feed error: %@.", error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
group.notify(queue: DispatchQueue.main) {
|
||||
completion(.success(()))
|
||||
} catch {
|
||||
os_log(.error, log: self.log, "Restore folder error: %@.", error.localizedDescription)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
func markArticles(for account: Account, articles: Set<Article>, statusKey: ArticleStatus.Key, flag: Bool) async throws {
|
||||
|
||||
try await withCheckedThrowingContinuation { continuation in
|
||||
self.markArticles(for: account, articles: articles, statusKey: statusKey, flag: flag) { result in
|
||||
switch result {
|
||||
case .success:
|
||||
continuation.resume()
|
||||
case .failure(let error):
|
||||
continuation.resume(throwing: error)
|
||||
}
|
||||
}
|
||||
let articles = try await account.update(articles: articles, statusKey: statusKey, flag: flag)
|
||||
|
||||
let syncStatuses = articles.map { article in
|
||||
return SyncStatus(articleID: article.articleID, key: SyncStatus.Key(statusKey), flag: flag)
|
||||
}
|
||||
}
|
||||
try? await syncDatabase.insertStatuses(Set(syncStatuses))
|
||||
|
||||
private func markArticles(for account: Account, articles: Set<Article>, statusKey: ArticleStatus.Key, flag: Bool, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
|
||||
Task { @MainActor in
|
||||
|
||||
do {
|
||||
|
||||
let articles = try await account.update(articles: articles, statusKey: statusKey, flag: flag)
|
||||
|
||||
let syncStatuses = articles.map { article in
|
||||
return SyncStatus(articleID: article.articleID, key: SyncStatus.Key(statusKey), flag: flag)
|
||||
}
|
||||
|
||||
try? await self.database.insertStatuses(syncStatuses)
|
||||
|
||||
if let count = try? await self.database.selectPendingCount(), count > 100 {
|
||||
self.sendArticleStatus(for: account) { _ in }
|
||||
}
|
||||
completion(.success(()))
|
||||
|
||||
} catch {
|
||||
completion(.failure(error))
|
||||
}
|
||||
if let count = try? await syncDatabase.selectPendingCount(), count > 100 {
|
||||
try await sendArticleStatus(for: account)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -817,32 +423,16 @@ final class NewsBlurAccountDelegate: AccountDelegate {
|
|||
}
|
||||
|
||||
func accountWillBeDeleted(_ account: Account) {
|
||||
caller.logout() { _ in }
|
||||
Task { @MainActor in
|
||||
try await caller.logout()
|
||||
}
|
||||
}
|
||||
|
||||
static func validateCredentials(transport: Transport, credentials: Credentials, endpoint: URL?, secretsProvider: SecretsProvider) async throws -> Credentials? {
|
||||
|
||||
try await withCheckedThrowingContinuation { continuation in
|
||||
|
||||
self.validateCredentials(transport: transport, credentials: credentials, endpoint: endpoint, secretsProvider: secretsProvider) { result in
|
||||
switch result {
|
||||
case .success(let credentials):
|
||||
continuation.resume(returning: credentials)
|
||||
case .failure(let error):
|
||||
continuation.resume(throwing: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class func validateCredentials(transport: Transport, credentials: Credentials, endpoint: URL? = nil, secretsProvider: SecretsProvider, completion: @escaping (Result<Credentials?, Error>) -> ()) {
|
||||
let caller = NewsBlurAPICaller(transport: transport)
|
||||
caller.credentials = credentials
|
||||
caller.validateCredentials() { result in
|
||||
DispatchQueue.main.async {
|
||||
completion(result)
|
||||
}
|
||||
}
|
||||
return try await caller.validateCredentials()
|
||||
}
|
||||
|
||||
// MARK: Suspend and Resume (for iOS)
|
||||
|
@ -856,7 +446,7 @@ final class NewsBlurAccountDelegate: AccountDelegate {
|
|||
func suspendDatabase() {
|
||||
|
||||
Task {
|
||||
await database.suspend()
|
||||
await syncDatabase.suspend()
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -865,7 +455,7 @@ final class NewsBlurAccountDelegate: AccountDelegate {
|
|||
|
||||
Task {
|
||||
caller.resume()
|
||||
await database.resume()
|
||||
await syncDatabase.resume()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -443,7 +443,7 @@ final class ReaderAPIAccountDelegate: AccountDelegate {
|
|||
return SyncStatus(articleID: article.articleID, key: SyncStatus.Key(statusKey), flag: flag)
|
||||
}
|
||||
|
||||
try await self.database.insertStatuses(syncStatuses)
|
||||
try await self.database.insertStatuses(Set(syncStatuses))
|
||||
|
||||
if let count = try await self.database.selectPendingCount(), count > 100 {
|
||||
try await sendArticleStatus(for: account)
|
||||
|
@ -693,10 +693,10 @@ private extension ReaderAPIAccountDelegate {
|
|||
|
||||
do {
|
||||
let _ = try await apiCall(articleIDGroup)
|
||||
try? await database.deleteSelectedForProcessing(articleIDGroup.map { $0 } )
|
||||
try? await database.deleteSelectedForProcessing(Set(articleIDGroup))
|
||||
} catch {
|
||||
os_log(.error, log: self.log, "Article status sync call failed: %@.", error.localizedDescription)
|
||||
try? await database.resetSelectedForProcessing(articleIDGroup.map { $0 } )
|
||||
try? await database.resetSelectedForProcessing(Set(articleIDGroup))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -28,7 +28,7 @@ public extension URLRequest {
|
|||
let auth = "Basic \(base64 ?? "")"
|
||||
setValue(auth, forHTTPHeaderField: HTTPRequestHeader.authorization)
|
||||
case .newsBlurBasic:
|
||||
setValue("application/x-www-form-urlencoded", forHTTPHeaderField: HTTPRequestHeader.contentType)
|
||||
setValue(MimeType.formURLEncoded, forHTTPHeaderField: HTTPRequestHeader.contentType)
|
||||
httpMethod = "POST"
|
||||
var postData = URLComponents()
|
||||
postData.queryItems = [
|
||||
|
@ -40,7 +40,7 @@ public extension URLRequest {
|
|||
setValue("\(NewsBlurAPICaller.sessionIDCookieKey)=\(credentials.secret)", forHTTPHeaderField: "Cookie")
|
||||
httpShouldHandleCookies = true
|
||||
case .readerBasic:
|
||||
setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
|
||||
setValue(MimeType.formURLEncoded, forHTTPHeaderField: "Content-Type")
|
||||
httpMethod = "POST"
|
||||
var postData = URLComponents()
|
||||
postData.queryItems = [
|
||||
|
|
|
@ -8,31 +8,28 @@
|
|||
|
||||
import Foundation
|
||||
import FoundationExtras
|
||||
import os
|
||||
|
||||
class DatabaseIDCache: @unchecked Sendable {
|
||||
final class DatabaseIDCache: Sendable {
|
||||
|
||||
static let shared = DatabaseIDCache()
|
||||
|
||||
private var databaseIDCache = [String: String]()
|
||||
private let databaseIDCacheLock = NSLock()
|
||||
private let databaseIDCache = OSAllocatedUnfairLock(initialState: [String: String]())
|
||||
|
||||
/// Generates — or retrieves from cache — a database-suitable ID based on a String.
|
||||
func databaseIDWithString(_ s: String) -> String {
|
||||
|
||||
databaseIDCacheLock.lock()
|
||||
defer {
|
||||
databaseIDCacheLock.unlock()
|
||||
}
|
||||
databaseIDCache.withLock { cache in
|
||||
if let identifier = cache[s] {
|
||||
return identifier
|
||||
}
|
||||
|
||||
if let identifier = databaseIDCache[s] {
|
||||
// MD5 works because:
|
||||
// * It’s fast
|
||||
// * Collisions aren’t going to happen with feed data
|
||||
let identifier = s.md5String
|
||||
cache[s] = identifier
|
||||
return identifier
|
||||
}
|
||||
|
||||
// MD5 works because:
|
||||
// * It’s fast
|
||||
// * Collisions aren’t going to happen with feed data
|
||||
let identifier = s.md5String
|
||||
databaseIDCache[s] = identifier
|
||||
return identifier
|
||||
}
|
||||
}
|
||||
|
|
|
@ -142,7 +142,7 @@ private extension CloudKitSendStatusOperation {
|
|||
// but the articles didn't come back in the fetch. We need to clean up those sync records
|
||||
// and stop processing.
|
||||
if statusUpdates.isEmpty {
|
||||
try? await self.database.deleteSelectedForProcessing(articleIDs)
|
||||
try? await self.database.deleteSelectedForProcessing(Set(articleIDs))
|
||||
done(true)
|
||||
return
|
||||
}
|
||||
|
@ -151,10 +151,10 @@ private extension CloudKitSendStatusOperation {
|
|||
Task { @MainActor in
|
||||
switch result {
|
||||
case .success:
|
||||
try? await self.database.deleteSelectedForProcessing(statusUpdates.map({ $0.articleID }))
|
||||
try? await self.database.deleteSelectedForProcessing(Set(statusUpdates.map({ $0.articleID })))
|
||||
done(false)
|
||||
case .failure(let error):
|
||||
try? await self.database.resetSelectedForProcessing(syncStatuses.map({ $0.articleID }))
|
||||
try? await self.database.resetSelectedForProcessing(Set(syncStatuses.map({ $0.articleID })))
|
||||
self.processAccountError(error)
|
||||
os_log(.error, log: self.log, "Send article status modify articles error: %@.", error.localizedDescription)
|
||||
completion(true)
|
||||
|
@ -162,7 +162,7 @@ private extension CloudKitSendStatusOperation {
|
|||
}
|
||||
}
|
||||
} catch {
|
||||
try? await self.database.resetSelectedForProcessing(syncStatuses.map({ $0.articleID }))
|
||||
try? await self.database.resetSelectedForProcessing(Set(syncStatuses.map({ $0.articleID })))
|
||||
os_log(.error, log: self.log, "Send article status fetch articles error: %@.", error.localizedDescription)
|
||||
completion(true)
|
||||
}
|
||||
|
|
|
@ -126,9 +126,7 @@ final class DetailWebViewController: NSViewController {
|
|||
webView.navigationDelegate = self
|
||||
webView.keyboardDelegate = keyboardDelegate
|
||||
webView.translatesAutoresizingMaskIntoConstraints = false
|
||||
if let userAgent = UserAgent.fromInfoPlist {
|
||||
webView.customUserAgent = userAgent
|
||||
}
|
||||
webView.customUserAgent = UserAgent.fromInfoPlist
|
||||
|
||||
view = webView
|
||||
|
||||
|
|
|
@ -790,7 +790,7 @@ extension MainWindowController: NSToolbarDelegate {
|
|||
|
||||
case .sidebarToggle:
|
||||
let title = NSLocalizedString("Toggle Sidebar", comment: "Toggle Sidebar")
|
||||
return buildToolbarButton(.toggleSidebar, title, AppAssets.sidebarToggleImage, "toggleTheSidebar:")
|
||||
return buildToolbarButton(.sidebarToggle, title, AppAssets.sidebarToggleImage, "toggleTheSidebar:")
|
||||
|
||||
case .refresh:
|
||||
let title = NSLocalizedString("Refresh", comment: "Refresh")
|
||||
|
|
|
@ -10,7 +10,7 @@ import Foundation
|
|||
|
||||
public struct NewsBlurStoryStatusChange: Sendable {
|
||||
|
||||
public let hashes: [String]
|
||||
public let hashes: Set<String>
|
||||
}
|
||||
|
||||
extension NewsBlurStoryStatusChange: NewsBlurDataConvertible {
|
||||
|
|
|
@ -35,194 +35,85 @@ public enum NewsBlurError: LocalizedError, Sendable {
|
|||
// 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, discard response
|
||||
func requestData(endpoint: String) async throws {
|
||||
|
||||
let callURL = baseURL.appendingPathComponent(endpoint)
|
||||
try await requestData(callURL: callURL)
|
||||
}
|
||||
|
||||
// GET endpoint
|
||||
func requestData<R: Decodable & Sendable>(
|
||||
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)
|
||||
/// GET endpoint
|
||||
func requestData<R: Decodable & Sendable>(endpoint: String, resultType: R.Type, dateDecoding: JSONDecoder.DateDecodingStrategy = .iso8601, keyDecoding: JSONDecoder.KeyDecodingStrategy = .useDefaultKeys) async throws -> (HTTPURLResponse, R?) {
|
||||
|
||||
requestData(
|
||||
callURL: callURL,
|
||||
resultType: resultType,
|
||||
dateDecoding: dateDecoding,
|
||||
keyDecoding: keyDecoding,
|
||||
completion: completion
|
||||
)
|
||||
let callURL = baseURL.appendingPathComponent(endpoint)
|
||||
return try await requestData(callURL: callURL, resultType: resultType, dateDecoding: dateDecoding, keyDecoding: keyDecoding)
|
||||
}
|
||||
|
||||
// POST to endpoint, discard response
|
||||
func sendUpdates(
|
||||
endpoint: String,
|
||||
payload: NewsBlurDataConvertible,
|
||||
completion: @escaping (Result<Void, Error>) -> Void
|
||||
) {
|
||||
let callURL = baseURL.appendingPathComponent(endpoint)
|
||||
/// POST to endpoint, discard response
|
||||
func sendUpdates(endpoint: String, payload: NewsBlurDataConvertible) async throws {
|
||||
|
||||
sendUpdates(callURL: callURL, payload: payload, completion: completion)
|
||||
let callURL = baseURL.appendingPathComponent(endpoint)
|
||||
try await sendUpdates(callURL: callURL, payload: payload)
|
||||
}
|
||||
|
||||
// POST to endpoint
|
||||
func sendUpdates<R: Decodable & Sendable>(
|
||||
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)
|
||||
/// POST to endpoint
|
||||
func sendUpdates<R: Decodable & Sendable>(endpoint: String, payload: NewsBlurDataConvertible, resultType: R.Type, dateDecoding: JSONDecoder.DateDecodingStrategy = .iso8601, keyDecoding: JSONDecoder.KeyDecodingStrategy = .useDefaultKeys) async throws -> (HTTPURLResponse, R?) {
|
||||
|
||||
sendUpdates(
|
||||
callURL: callURL,
|
||||
payload: payload,
|
||||
resultType: resultType,
|
||||
dateDecoding: dateDecoding,
|
||||
keyDecoding: keyDecoding,
|
||||
completion: completion
|
||||
)
|
||||
let callURL = baseURL.appendingPathComponent(endpoint)
|
||||
return try await sendUpdates(callURL: callURL, payload: payload, resultType: resultType, dateDecoding: dateDecoding, keyDecoding: keyDecoding)
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
/// GET URL with params, discard response
|
||||
func requestData(callURL: URL) async throws {
|
||||
|
||||
guard !isSuspended else { throw TransportError.suspended }
|
||||
|
||||
let request = URLRequest(url: callURL, newsBlurCredentials: credentials)
|
||||
|
||||
Task { @MainActor in
|
||||
|
||||
do {
|
||||
try await transport.send(request: request)
|
||||
completion(.success(()))
|
||||
} catch {
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
try await transport.send(request: request)
|
||||
}
|
||||
|
||||
// GET URL with params
|
||||
func requestData<R: Decodable & Sendable>(
|
||||
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
|
||||
}
|
||||
/// GET URL with params
|
||||
@discardableResult
|
||||
func requestData<R: Decodable & Sendable>(callURL: URL, resultType: R.Type, dateDecoding: JSONDecoder.DateDecodingStrategy = .iso8601, keyDecoding: JSONDecoder.KeyDecodingStrategy = .useDefaultKeys) async throws -> (HTTPURLResponse, R?) {
|
||||
|
||||
guard !isSuspended else { throw TransportError.suspended }
|
||||
|
||||
let request = URLRequest(url: callURL, newsBlurCredentials: credentials)
|
||||
|
||||
Task { @MainActor in
|
||||
|
||||
do {
|
||||
let response = try await transport.send(request: request, resultType: resultType, dateDecoding: dateDecoding, keyDecoding: keyDecoding)
|
||||
|
||||
if self.suspended {
|
||||
completion(.failure(TransportError.suspended))
|
||||
return
|
||||
}
|
||||
completion(.success(response))
|
||||
|
||||
} catch {
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
let response = try await transport.send(request: request, resultType: resultType, dateDecoding: dateDecoding, keyDecoding: keyDecoding)
|
||||
return response
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
/// POST to URL with params, discard response
|
||||
func sendUpdates(callURL: URL, payload: NewsBlurDataConvertible) async throws {
|
||||
|
||||
guard !isSuspended else { throw TransportError.suspended }
|
||||
|
||||
var request = URLRequest(url: callURL, newsBlurCredentials: credentials)
|
||||
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: HTTPRequestHeader.contentType)
|
||||
request.setValue(MimeType.formURLEncoded, forHTTPHeaderField: HTTPRequestHeader.contentType)
|
||||
request.httpBody = payload.asData
|
||||
|
||||
Task { @MainActor in
|
||||
|
||||
do {
|
||||
try await transport.send(request: request, method: HTTPMethod.post)
|
||||
if self.suspended {
|
||||
completion(.failure(TransportError.suspended))
|
||||
return
|
||||
}
|
||||
completion(.success(()))
|
||||
} catch {
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
try await transport.send(request: request, method: HTTPMethod.post)
|
||||
}
|
||||
|
||||
// POST to URL with params
|
||||
func sendUpdates<R: Decodable & Sendable>(
|
||||
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
|
||||
}
|
||||
/// POST to URL with params
|
||||
func sendUpdates<R: Decodable & Sendable>(callURL: URL, payload: NewsBlurDataConvertible, resultType: R.Type, dateDecoding: JSONDecoder.DateDecodingStrategy = .iso8601, keyDecoding: JSONDecoder.KeyDecodingStrategy = .useDefaultKeys) async throws -> (HTTPURLResponse, R?) {
|
||||
|
||||
guard !isSuspended else { throw TransportError.suspended }
|
||||
|
||||
guard let data = payload.asData else {
|
||||
completion(.failure(NewsBlurError.invalidParameter))
|
||||
return
|
||||
throw NewsBlurError.invalidParameter
|
||||
}
|
||||
|
||||
var request = URLRequest(url: callURL, newsBlurCredentials: credentials)
|
||||
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: HTTPRequestHeader.contentType)
|
||||
request.setValue(MimeType.formURLEncoded, forHTTPHeaderField: HTTPRequestHeader.contentType)
|
||||
|
||||
Task { @MainActor in
|
||||
|
||||
do {
|
||||
|
||||
let response = try await transport.send(request: request, method: HTTPMethod.post, data: data, resultType: resultType, dateDecoding: dateDecoding, keyDecoding: keyDecoding)
|
||||
|
||||
if self.suspended {
|
||||
completion(.failure(TransportError.suspended))
|
||||
return
|
||||
}
|
||||
|
||||
completion(.success(response))
|
||||
|
||||
} catch {
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
let response = try await transport.send(request: request, method: HTTPMethod.post, data: data, resultType: resultType, dateDecoding: dateDecoding, keyDecoding: keyDecoding)
|
||||
return response
|
||||
}
|
||||
}
|
||||
|
|
|
@ -16,7 +16,7 @@ import Secrets
|
|||
|
||||
let baseURL = URL(string: "https://www.newsblur.com/")!
|
||||
var transport: Transport!
|
||||
var suspended = false
|
||||
var isSuspended = false
|
||||
|
||||
public var credentials: Credentials?
|
||||
|
||||
|
@ -28,253 +28,165 @@ import Secrets
|
|||
/// Cancels all pending requests rejects any that come in later
|
||||
public func suspend() {
|
||||
transport.cancelAll()
|
||||
suspended = true
|
||||
isSuspended = true
|
||||
}
|
||||
|
||||
public func resume() {
|
||||
suspended = false
|
||||
isSuspended = false
|
||||
}
|
||||
|
||||
public 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
|
||||
}
|
||||
public func validateCredentials() async throws -> Credentials? {
|
||||
|
||||
guard let username = self.credentials?.username else {
|
||||
completion(.failure(NewsBlurError.unknown))
|
||||
return
|
||||
}
|
||||
let (response, payload) = try await requestData(endpoint: "api/login", resultType: NewsBlurLoginResponse.self)
|
||||
|
||||
let cookies = HTTPCookie.cookies(withResponseHeaderFields: headerFields, for: url)
|
||||
for cookie in cookies where cookie.name == Self.sessionIDCookieKey {
|
||||
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))
|
||||
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 {
|
||||
throw NewsBlurError.general(message: message)
|
||||
}
|
||||
throw NewsBlurError.unknown
|
||||
}
|
||||
|
||||
guard let username = self.credentials?.username else {
|
||||
throw NewsBlurError.unknown
|
||||
}
|
||||
|
||||
let cookies = HTTPCookie.cookies(withResponseHeaderFields: headerFields, for: url)
|
||||
for cookie in cookies where cookie.name == Self.sessionIDCookieKey {
|
||||
let credentials = Credentials(type: .newsBlurSessionID, username: username, secret: cookie.value)
|
||||
return credentials
|
||||
}
|
||||
|
||||
throw NewsBlurError.general(message: "Failed to retrieve session")
|
||||
}
|
||||
|
||||
public func logout() async throws {
|
||||
|
||||
try await requestData(endpoint: "api/logout")
|
||||
}
|
||||
|
||||
public func retrieveFeeds() async throws -> ([NewsBlurFeed]?, [NewsBlurFolder]?) {
|
||||
|
||||
let url: URL! = baseURL
|
||||
.appendingPathComponent("reader/feeds")
|
||||
.appendingQueryItems([
|
||||
URLQueryItem(name: "flat", value: "true"),
|
||||
URLQueryItem(name: "update_counts", value: "false"),
|
||||
])
|
||||
|
||||
let (_, payload) = try await requestData(callURL: url, resultType: NewsBlurFeedsResponse.self)
|
||||
return (payload?.feeds, payload?.folders)
|
||||
}
|
||||
|
||||
func retrieveStoryHashes(endpoint: String) async throws -> Set<NewsBlurStoryHash>? {
|
||||
|
||||
let url: URL! = baseURL
|
||||
.appendingPathComponent(endpoint)
|
||||
.appendingQueryItems([
|
||||
URLQueryItem(name: "include_timestamps", value: "true"),
|
||||
])
|
||||
|
||||
let (_, payload) = try await requestData(callURL: url, resultType: NewsBlurStoryHashesResponse.self, dateDecoding: .secondsSince1970)
|
||||
|
||||
if let hashes = payload?.unread ?? payload?.starred {
|
||||
return Set(hashes)
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
public func logout(completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
requestData(endpoint: "api/logout", completion: completion)
|
||||
public func retrieveUnreadStoryHashes() async throws -> Set<NewsBlurStoryHash>? {
|
||||
|
||||
return try await retrieveStoryHashes(endpoint: "reader/unread_story_hashes")
|
||||
}
|
||||
|
||||
public 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"),
|
||||
])
|
||||
public func retrieveStarredStoryHashes() async throws -> Set<NewsBlurStoryHash>? {
|
||||
|
||||
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))
|
||||
}
|
||||
}
|
||||
return try await retrieveStoryHashes(endpoint: "reader/starred_story_hashes")
|
||||
}
|
||||
|
||||
func retrieveStoryHashes(endpoint: String, completion: @escaping (Result<[NewsBlurStoryHash]?, Error>) -> Void) {
|
||||
let url = baseURL
|
||||
.appendingPathComponent(endpoint)
|
||||
.appendingQueryItems([
|
||||
URLQueryItem(name: "include_timestamps", value: "true"),
|
||||
])
|
||||
public func retrieveStories(feedID: String, page: Int) async throws -> ([NewsBlurStory]?, Date?) {
|
||||
|
||||
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))
|
||||
}
|
||||
}
|
||||
let url: 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: "false"),
|
||||
URLQueryItem(name: "include_story_content", value: "true"),
|
||||
])
|
||||
|
||||
let (response, payload) = try await requestData(callURL: url, resultType: NewsBlurStoriesResponse.self)
|
||||
return (payload?.stories, HTTPDateInfo(urlResponse: response)?.date)
|
||||
}
|
||||
|
||||
public func retrieveUnreadStoryHashes(completion: @escaping (Result<[NewsBlurStoryHash]?, Error>) -> Void) {
|
||||
retrieveStoryHashes(
|
||||
endpoint: "reader/unread_story_hashes",
|
||||
completion: completion
|
||||
)
|
||||
public func retrieveStories(hashes: [NewsBlurStoryHash]) async throws -> ([NewsBlurStory]?, Date?) {
|
||||
|
||||
let url: URL! = baseURL
|
||||
.appendingPathComponent("reader/river_stories")
|
||||
.appendingQueryItem(.init(name: "include_hidden", value: "false"))?
|
||||
.appendingQueryItems(hashes.map {
|
||||
URLQueryItem(name: "h", value: $0.hash)
|
||||
})
|
||||
|
||||
let (response, payload) = try await requestData(callURL: url, resultType: NewsBlurStoriesResponse.self)
|
||||
return (payload?.stories, HTTPDateInfo(urlResponse: response)?.date)
|
||||
}
|
||||
|
||||
public func retrieveStarredStoryHashes(completion: @escaping (Result<[NewsBlurStoryHash]?, Error>) -> Void) {
|
||||
retrieveStoryHashes(
|
||||
endpoint: "reader/starred_story_hashes",
|
||||
completion: completion
|
||||
)
|
||||
public func markAsUnread(hashes: Set<String>) async throws {
|
||||
|
||||
try await sendUpdates(endpoint: "reader/mark_story_hash_as_unread", payload: NewsBlurStoryStatusChange(hashes: hashes))
|
||||
}
|
||||
|
||||
public 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: "false"),
|
||||
URLQueryItem(name: "include_story_content", value: "true"),
|
||||
])
|
||||
public func markAsRead(hashes: Set<String>) async throws {
|
||||
|
||||
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))
|
||||
}
|
||||
}
|
||||
try await sendUpdates(endpoint: "reader/mark_story_hashes_as_read", payload: NewsBlurStoryStatusChange(hashes: hashes))
|
||||
}
|
||||
|
||||
public func retrieveStories(hashes: [NewsBlurStoryHash], completion: @escaping (Result<([NewsBlurStory]?, Date?), Error>) -> Void) {
|
||||
let url = baseURL
|
||||
.appendingPathComponent("reader/river_stories")
|
||||
.appendingQueryItem(.init(name: "include_hidden", value: "false"))?
|
||||
.appendingQueryItems(hashes.map {
|
||||
URLQueryItem(name: "h", value: $0.hash)
|
||||
})
|
||||
public func star(hashes: Set<String>) async throws {
|
||||
|
||||
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))
|
||||
}
|
||||
}
|
||||
try await sendUpdates(endpoint: "reader/mark_story_hash_as_starred", payload: NewsBlurStoryStatusChange(hashes: hashes))
|
||||
}
|
||||
|
||||
public func markAsUnread(hashes: [String], completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
sendUpdates(
|
||||
endpoint: "reader/mark_story_hash_as_unread",
|
||||
payload: NewsBlurStoryStatusChange(hashes: hashes),
|
||||
completion: completion
|
||||
)
|
||||
public func unstar(hashes: Set<String>) async throws {
|
||||
|
||||
try await sendUpdates(endpoint: "reader/mark_story_hash_as_unstarred", payload: NewsBlurStoryStatusChange(hashes: hashes))
|
||||
}
|
||||
|
||||
public func markAsRead(hashes: [String], completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
sendUpdates(
|
||||
endpoint: "reader/mark_story_hashes_as_read",
|
||||
payload: NewsBlurStoryStatusChange(hashes: hashes),
|
||||
completion: completion
|
||||
)
|
||||
public func addFolder(named name: String) async throws {
|
||||
|
||||
try await sendUpdates(endpoint: "reader/add_folder", payload: NewsBlurFolderChange.add(name))
|
||||
}
|
||||
|
||||
public func star(hashes: [String], completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
sendUpdates(
|
||||
endpoint: "reader/mark_story_hash_as_starred",
|
||||
payload: NewsBlurStoryStatusChange(hashes: hashes),
|
||||
completion: completion
|
||||
)
|
||||
public func renameFolder(with folder: String, to name: String) async throws {
|
||||
|
||||
try await sendUpdates(endpoint: "reader/rename_folder", payload: NewsBlurFolderChange.rename(folder, name))
|
||||
}
|
||||
|
||||
public func unstar(hashes: [String], completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
sendUpdates(
|
||||
endpoint: "reader/mark_story_hash_as_unstarred",
|
||||
payload: NewsBlurStoryStatusChange(hashes: hashes),
|
||||
completion: completion
|
||||
)
|
||||
public func removeFolder(named name: String, feedIDs: [String]) async throws {
|
||||
|
||||
try await sendUpdates(endpoint: "reader/delete_folder", payload: NewsBlurFolderChange.delete(name, feedIDs))
|
||||
}
|
||||
|
||||
public func addFolder(named name: String, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
sendUpdates(
|
||||
endpoint: "reader/add_folder",
|
||||
payload: NewsBlurFolderChange.add(name),
|
||||
completion: completion
|
||||
)
|
||||
public func addURL(_ url: String, folder: String?) async throws -> NewsBlurFeed? {
|
||||
|
||||
let (_, payload) = try await sendUpdates(endpoint: "reader/add_url", payload: NewsBlurFeedChange.add(url, folder), resultType: NewsBlurAddURLResponse.self)
|
||||
return payload?.feed
|
||||
}
|
||||
|
||||
public 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
|
||||
)
|
||||
public func renameFeed(feedID: String, newName: String) async throws {
|
||||
|
||||
try await sendUpdates(endpoint: "reader/rename_feed", payload: NewsBlurFeedChange.rename(feedID, newName))
|
||||
}
|
||||
|
||||
public 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
|
||||
)
|
||||
public func deleteFeed(feedID: String, folder: String? = nil) async throws {
|
||||
|
||||
try await sendUpdates(endpoint: "reader/delete_feed", payload: NewsBlurFeedChange.delete(feedID, folder))
|
||||
}
|
||||
|
||||
public 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))
|
||||
}
|
||||
}
|
||||
}
|
||||
public func moveFeed(feedID: String, from: String?, to: String?) async throws {
|
||||
|
||||
public 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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public 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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public 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))
|
||||
}
|
||||
}
|
||||
try await sendUpdates(endpoint: "reader/move_feed_to_folder", payload: NewsBlurFeedChange.move(feedID, from, to))
|
||||
}
|
||||
}
|
||||
|
|
|
@ -24,7 +24,7 @@ public extension URLRequest {
|
|||
|
||||
if credentialsType == .newsBlurBasic {
|
||||
|
||||
setValue("application/x-www-form-urlencoded", forHTTPHeaderField: HTTPRequestHeader.contentType)
|
||||
setValue(MimeType.formURLEncoded, forHTTPHeaderField: HTTPRequestHeader.contentType)
|
||||
httpMethod = "POST"
|
||||
|
||||
var postData = URLComponents()
|
||||
|
|
|
@ -209,7 +209,7 @@ public enum CreateReaderAPISubscriptionResult: Sendable {
|
|||
|
||||
var request = URLRequest(url: baseURL.appendingPathComponent(ReaderAPIEndpoints.renameTag.rawValue), readerAPICredentials: self.credentials)
|
||||
self.addVariantHeaders(&request)
|
||||
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
|
||||
request.setValue(MimeType.formURLEncoded, forHTTPHeaderField: "Content-Type")
|
||||
request.httpMethod = "POST"
|
||||
|
||||
guard let encodedOldName = self.encodeForURLPath(oldName), let encodedNewName = self.encodeForURLPath(newName) else {
|
||||
|
@ -234,7 +234,7 @@ public enum CreateReaderAPISubscriptionResult: Sendable {
|
|||
|
||||
var request = URLRequest(url: baseURL.appendingPathComponent(ReaderAPIEndpoints.disableTag.rawValue), readerAPICredentials: self.credentials)
|
||||
self.addVariantHeaders(&request)
|
||||
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
|
||||
request.setValue(MimeType.formURLEncoded, forHTTPHeaderField: "Content-Type")
|
||||
request.httpMethod = "POST"
|
||||
|
||||
let postData = "T=\(token)&s=\(folderExternalID)".data(using: String.Encoding.utf8)
|
||||
|
@ -276,7 +276,7 @@ public enum CreateReaderAPISubscriptionResult: Sendable {
|
|||
|
||||
var request = URLRequest(url: callURL, readerAPICredentials: self.credentials)
|
||||
self.addVariantHeaders(&request)
|
||||
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
|
||||
request.setValue(MimeType.formURLEncoded, forHTTPHeaderField: "Content-Type")
|
||||
request.httpMethod = "POST"
|
||||
|
||||
guard let encodedFeedURL = self.encodeForURLPath(url) else {
|
||||
|
@ -321,7 +321,7 @@ public enum CreateReaderAPISubscriptionResult: Sendable {
|
|||
|
||||
var request = URLRequest(url: baseURL.appendingPathComponent(ReaderAPIEndpoints.subscriptionEdit.rawValue), readerAPICredentials: self.credentials)
|
||||
self.addVariantHeaders(&request)
|
||||
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
|
||||
request.setValue(MimeType.formURLEncoded, forHTTPHeaderField: "Content-Type")
|
||||
request.httpMethod = "POST"
|
||||
|
||||
let postData = "T=\(token)&s=\(subscriptionID)&ac=unsubscribe".data(using: String.Encoding.utf8)
|
||||
|
@ -357,7 +357,7 @@ public enum CreateReaderAPISubscriptionResult: Sendable {
|
|||
|
||||
var request = URLRequest(url: baseURL.appendingPathComponent(ReaderAPIEndpoints.subscriptionEdit.rawValue), readerAPICredentials: self.credentials)
|
||||
self.addVariantHeaders(&request)
|
||||
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
|
||||
request.setValue(MimeType.formURLEncoded, forHTTPHeaderField: "Content-Type")
|
||||
request.httpMethod = "POST"
|
||||
|
||||
var postString = "T=\(token)&s=\(subscriptionID)&ac=edit"
|
||||
|
@ -388,7 +388,7 @@ public enum CreateReaderAPISubscriptionResult: Sendable {
|
|||
|
||||
var request = URLRequest(url: baseURL.appendingPathComponent(ReaderAPIEndpoints.contents.rawValue), readerAPICredentials: self.credentials)
|
||||
self.addVariantHeaders(&request)
|
||||
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
|
||||
request.setValue(MimeType.formURLEncoded, forHTTPHeaderField: "Content-Type")
|
||||
request.httpMethod = "POST"
|
||||
|
||||
// Get ids from above into hex representation of value
|
||||
|
@ -559,7 +559,7 @@ private extension ReaderAPICaller {
|
|||
// Do POST asking for data about all the new articles
|
||||
var request = URLRequest(url: baseURL.appendingPathComponent(ReaderAPIEndpoints.editTag.rawValue), readerAPICredentials: self.credentials)
|
||||
self.addVariantHeaders(&request)
|
||||
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
|
||||
request.setValue(MimeType.formURLEncoded, forHTTPHeaderField: "Content-Type")
|
||||
request.httpMethod = "POST"
|
||||
|
||||
// Get ids from above into hex representation of value
|
||||
|
|
|
@ -24,7 +24,7 @@ extension URLRequest {
|
|||
|
||||
if credentialsType == .readerBasic {
|
||||
|
||||
setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
|
||||
setValue(MimeType.formURLEncoded, forHTTPHeaderField: "Content-Type")
|
||||
httpMethod = "POST"
|
||||
var postData = URLComponents()
|
||||
postData.queryItems = [
|
||||
|
|
|
@ -28,7 +28,7 @@ public actor SyncDatabase {
|
|||
|
||||
// MARK: - API
|
||||
|
||||
public func insertStatuses(_ statuses: [SyncStatus]) throws {
|
||||
public func insertStatuses(_ statuses: Set<SyncStatus>) throws {
|
||||
|
||||
guard let database else {
|
||||
throw DatabaseError.suspended
|
||||
|
@ -76,7 +76,7 @@ public actor SyncDatabase {
|
|||
syncStatusTable.resetAllSelectedForProcessing(database: database)
|
||||
}
|
||||
|
||||
public func resetSelectedForProcessing(_ articleIDs: [String]) throws {
|
||||
public func resetSelectedForProcessing(_ articleIDs: Set<String>) throws {
|
||||
|
||||
guard let database else {
|
||||
throw DatabaseError.suspended
|
||||
|
@ -84,7 +84,7 @@ public actor SyncDatabase {
|
|||
syncStatusTable.resetSelectedForProcessing(articleIDs, database: database)
|
||||
}
|
||||
|
||||
public func deleteSelectedForProcessing(_ articleIDs: [String]) throws {
|
||||
public func deleteSelectedForProcessing(_ articleIDs: Set<String>) throws {
|
||||
|
||||
guard let database else {
|
||||
throw DatabaseError.suspended
|
||||
|
|
|
@ -62,7 +62,7 @@ struct SyncStatusTable {
|
|||
database.executeUpdateInTransaction(updateSQL)
|
||||
}
|
||||
|
||||
func resetSelectedForProcessing(_ articleIDs: [String], database: FMDatabase) {
|
||||
func resetSelectedForProcessing(_ articleIDs: Set<String>, database: FMDatabase) {
|
||||
|
||||
guard !articleIDs.isEmpty else {
|
||||
return
|
||||
|
@ -75,7 +75,7 @@ struct SyncStatusTable {
|
|||
database.executeUpdateInTransaction(updateSQL, withArgumentsIn: parameters)
|
||||
}
|
||||
|
||||
func deleteSelectedForProcessing(_ articleIDs: [String], database: FMDatabase) {
|
||||
func deleteSelectedForProcessing(_ articleIDs: Set<String>, database: FMDatabase) {
|
||||
|
||||
guard !articleIDs.isEmpty else {
|
||||
return
|
||||
|
@ -88,7 +88,7 @@ struct SyncStatusTable {
|
|||
database.executeUpdateInTransaction(deleteSQL, withArgumentsIn: parameters)
|
||||
}
|
||||
|
||||
func insertStatuses(_ statuses: [SyncStatus], database: FMDatabase) {
|
||||
func insertStatuses(_ statuses: Set<SyncStatus>, database: FMDatabase) {
|
||||
|
||||
database.beginTransaction()
|
||||
|
||||
|
|
|
@ -17,6 +17,8 @@ public struct MimeType {
|
|||
public static let jpg = "image/jpg"
|
||||
public static let gif = "image/gif"
|
||||
public static let tiff = "image/tiff"
|
||||
|
||||
public static let formURLEncoded = "application/x-www-form-urlencoded"
|
||||
}
|
||||
|
||||
public extension String {
|
||||
|
|
Loading…
Reference in New Issue