Compare commits

...

6 Commits

Author SHA1 Message Date
Brent Simmons d58821a7ad Convert NewsBlur to async await. 2024-05-13 21:59:42 -07:00
Brent Simmons be4564716f Convert NewsBlurAPICaller to async await. 2024-05-08 22:44:19 -07:00
Brent Simmons a3151181eb Add and use MimeType.formURLEncoded. 2024-05-08 22:18:45 -07:00
Brent Simmons 48bfcedbf7 Simplify and improve DatabaseIDCache. 2024-05-08 21:57:23 -07:00
Brent Simmons ae77aece2a Make DatabaseIDCache properly Sendable (instead of unchecked) by using an OSAllocatedUnfairLock. 2024-05-07 19:58:23 -07:00
Brent Simmons 138177858c Fix build error. 2024-05-07 19:57:51 -07:00
12 changed files with 509 additions and 1227 deletions

View File

@ -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(storyHashGroup.map { String($0) } )
}
}
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)
}
}
}

View File

@ -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(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()
}
}
}

View File

@ -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 = [

View File

@ -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:
// * Its fast
// * Collisions arent going to happen with feed data
let identifier = s.md5String
cache[s] = identifier
return identifier
}
// MD5 works because:
// * Its fast
// * Collisions arent going to happen with feed data
let identifier = s.md5String
databaseIDCache[s] = identifier
return identifier
}
}

View File

@ -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

View File

@ -10,7 +10,7 @@ import Foundation
public struct NewsBlurStoryStatusChange: Sendable {
public let hashes: [String]
public let hashes: Set<String>
}
extension NewsBlurStoryStatusChange: NewsBlurDataConvertible {

View File

@ -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
}
}

View File

@ -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))
}
}

View File

@ -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()

View File

@ -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

View File

@ -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 = [

View File

@ -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 {