diff --git a/Shared/ErrorHandling/ErrorConstants.swift b/Shared/ErrorHandling/ErrorConstants.swift index b412fdb..88ae92b 100644 --- a/Shared/ErrorHandling/ErrorConstants.swift +++ b/Shared/ErrorHandling/ErrorConstants.swift @@ -1,169 +1,173 @@ import Foundation // MARK: - Network Errors enum NetworkError: Error { case noConnectionError } extension NetworkError: LocalizedError { public var errorDescription: String? { switch self { case .noConnectionError: return NSLocalizedString( "There is no internet connection at the moment. Please reconnect or try again later.", comment: "" ) } } } // MARK: - Keychain Errors enum KeychainError: Error { case couldNotStoreAccessToken case couldNotPurgeAccessToken case couldNotFetchAccessToken } extension KeychainError: LocalizedError { public var errorDescription: String? { switch self { case .couldNotStoreAccessToken: return NSLocalizedString("There was a problem storing your access token in the Keychain.", comment: "") case .couldNotPurgeAccessToken: return NSLocalizedString("Something went wrong purging the token from the Keychain.", comment: "") case .couldNotFetchAccessToken: return NSLocalizedString("Something went wrong fetching the token from the Keychain.", comment: "") } } } // MARK: - Account Errors enum AccountError: Error { case invalidPassword case usernameNotFound case serverNotFound case invalidServerURL case unknownLoginError case genericAuthError } extension AccountError: LocalizedError { public var errorDescription: String? { switch self { case .serverNotFound: return NSLocalizedString( "The server could not be found. Please check the information you've entered and try again.", comment: "" ) case .invalidPassword: return NSLocalizedString( "Invalid password. Please check that you've entered your password correctly and try logging in again.", comment: "" ) case .usernameNotFound: return NSLocalizedString( "Username not found. Did you use your email address by mistake?", comment: "" ) case .invalidServerURL: return NSLocalizedString( "Please enter a valid instance domain name. It should look like \"https://example.com\" or \"write.as\".", // swiftlint:disable:this line_length comment: "" ) case .genericAuthError: return NSLocalizedString("Something went wrong, please try logging in again.", comment: "") case .unknownLoginError: return NSLocalizedString("An unknown error occurred while trying to login.", comment: "") } } } // MARK: - User Defaults Errors enum UserDefaultsError: Error { case couldNotMigrateStandardDefaults } extension UserDefaultsError: LocalizedError { public var errorDescription: String? { switch self { case .couldNotMigrateStandardDefaults: return NSLocalizedString("Could not migrate user defaults to group container", comment: "") } } } // MARK: - Local Store Errors enum LocalStoreError: Error { case couldNotSaveContext case couldNotFetchCollections case couldNotFetchPosts(String = "") - case couldNotPurgePublishedPosts + case couldNotPurgePosts(String = "") case couldNotPurgeCollections case couldNotLoadStore(String) case couldNotMigrateStore(String) case couldNotDeleteStoreAfterMigration(String) case genericError(String = "") } extension LocalStoreError: LocalizedError { public var errorDescription: String? { switch self { case .couldNotSaveContext: return NSLocalizedString("Error saving context", comment: "") case .couldNotFetchCollections: return NSLocalizedString("Failed to fetch blogs from local store.", comment: "") case .couldNotFetchPosts(let postFilter): if postFilter.isEmpty { return NSLocalizedString("Failed to fetch posts from local store.", comment: "") } else { return NSLocalizedString("Failed to fetch \(postFilter) posts from local store.", comment: "") } - case .couldNotPurgePublishedPosts: - return NSLocalizedString("Failed to purge published posts from local store.", comment: "") + case .couldNotPurgePosts(let postFilter): + if postFilter.isEmpty { + return NSLocalizedString("Failed to purge \(postFilter) posts from local store.", comment: "") + } else { + return NSLocalizedString("Failed to purge posts from local store.", comment: "") + } case .couldNotPurgeCollections: return NSLocalizedString("Failed to purge cached collections", comment: "") case .couldNotLoadStore(let errorDescription): return NSLocalizedString("Something went wrong loading local store: \(errorDescription)", comment: "") case .couldNotMigrateStore(let errorDescription): return NSLocalizedString("Something went wrong migrating local store: \(errorDescription)", comment: "") case .couldNotDeleteStoreAfterMigration(let errorDescription): return NSLocalizedString("Something went wrong deleting old store: \(errorDescription)", comment: "") case .genericError(let customContent): if customContent.isEmpty { return NSLocalizedString("Something went wrong accessing device storage", comment: "") } else { return NSLocalizedString(customContent, comment: "") } } } } // MARK: - Application Errors enum AppError: Error { case couldNotGetLoggedInClient case couldNotGetPostId case genericError(String = "") } extension AppError: LocalizedError { public var errorDescription: String? { switch self { case .couldNotGetLoggedInClient: return NSLocalizedString("Something went wrong trying to access the WriteFreely client.", comment: "") case .couldNotGetPostId: return NSLocalizedString("Something went wrong trying to get the post's unique ID.", comment: "") case .genericError(let customContent): if customContent.isEmpty { return NSLocalizedString("Something went wrong", comment: "") } else { return NSLocalizedString(customContent, comment: "") } } } } diff --git a/Shared/Extensions/WriteFreelyModel+APIHandlers.swift b/Shared/Extensions/WriteFreelyModel+APIHandlers.swift index c1d19d9..b6dc7f3 100644 --- a/Shared/Extensions/WriteFreelyModel+APIHandlers.swift +++ b/Shared/Extensions/WriteFreelyModel+APIHandlers.swift @@ -1,261 +1,269 @@ import Foundation import WriteFreely extension WriteFreelyModel { func loginHandler(result: Result) { DispatchQueue.main.async { self.isLoggingIn = false } do { let user = try result.get() fetchUserCollections() fetchUserPosts() do { try saveTokenToKeychain(user.token, username: user.username, server: account.server) DispatchQueue.main.async { self.account.login(user) } } catch { self.currentError = KeychainError.couldNotStoreAccessToken } } catch WFError.notFound { self.currentError = AccountError.usernameNotFound } catch WFError.unauthorized { self.currentError = AccountError.invalidPassword } catch { if (error as NSError).domain == NSURLErrorDomain, (error as NSError).code == -1003 { self.currentError = AccountError.serverNotFound } else { self.currentError = error } } } func logoutHandler(result: Result) { do { _ = try result.get() do { try purgeTokenFromKeychain(username: account.user?.username, server: account.server) client = nil DispatchQueue.main.async { self.account.logout() LocalStorageManager.standard.purgeUserCollections() - self.posts.purgePublishedPosts() + do { + try self.posts.purgePublishedPosts() + } catch { + self.currentError = error + } } } catch { self.currentError = KeychainError.couldNotPurgeAccessToken } } catch WFError.notFound { // The user token is invalid or doesn't exist, so it's been invalidated by the server. Proceed with // purging the token from the Keychain, destroying the client object, and setting the AccountModel to its // logged-out state. do { try purgeTokenFromKeychain(username: account.user?.username, server: account.server) client = nil DispatchQueue.main.async { self.account.logout() LocalStorageManager.standard.purgeUserCollections() - self.posts.purgePublishedPosts() + do { + try self.posts.purgePublishedPosts() + } catch { + self.currentError = error + } } } catch { self.currentError = KeychainError.couldNotPurgeAccessToken } } catch { // We get a 'cannot parse response' (similar to what we were seeing in the Swift package) NSURLError here, // so we're using a hacky workaround — if we get the NSURLError, but the AccountModel still thinks we're // logged in, try calling the logout function again and see what we get. // Conditional cast from 'Error' to 'NSError' always succeeds but is the only way to check error properties. if (error as NSError).domain == NSURLErrorDomain, (error as NSError).code == NSURLErrorCannotParseResponse { if account.isLoggedIn { self.logout() } } } } func fetchUserCollectionsHandler(result: Result<[WFCollection], Error>) { // We're done with the network request. DispatchQueue.main.async { self.isProcessingRequest = false } do { let fetchedCollections = try result.get() for fetchedCollection in fetchedCollections { DispatchQueue.main.async { let localCollection = WFACollection(context: LocalStorageManager.standard.container.viewContext) localCollection.alias = fetchedCollection.alias localCollection.blogDescription = fetchedCollection.description localCollection.email = fetchedCollection.email localCollection.isPublic = fetchedCollection.isPublic ?? false localCollection.styleSheet = fetchedCollection.styleSheet localCollection.title = fetchedCollection.title localCollection.url = fetchedCollection.url } } DispatchQueue.main.async { LocalStorageManager.standard.saveContext() } } catch WFError.unauthorized { self.currentError = AccountError.genericAuthError self.logout() } catch { self.currentError = AppError.genericError(error.localizedDescription) } } func fetchUserPostsHandler(result: Result<[WFPost], Error>) { // We're done with the network request. DispatchQueue.main.async { self.isProcessingRequest = false } let request = WFAPost.createFetchRequest() do { let locallyCachedPosts = try LocalStorageManager.standard.container.viewContext.fetch(request) do { var postsToDelete = locallyCachedPosts.filter { $0.status != PostStatus.local.rawValue } let fetchedPosts = try result.get() for fetchedPost in fetchedPosts { if let managedPost = locallyCachedPosts.first(where: { $0.postId == fetchedPost.postId }) { DispatchQueue.main.async { managedPost.wasDeletedFromServer = false if let fetchedPostUpdatedDate = fetchedPost.updatedDate, let localPostUpdatedDate = managedPost.updatedDate { managedPost.hasNewerRemoteCopy = fetchedPostUpdatedDate > localPostUpdatedDate } else { self.currentError = AppError.genericError( "Error updating post: could not determine which copy of post is newer." ) } postsToDelete.removeAll(where: { $0.postId == fetchedPost.postId }) } } else { DispatchQueue.main.async { let managedPost = WFAPost(context: LocalStorageManager.standard.container.viewContext) self.importData(from: fetchedPost, into: managedPost) managedPost.collectionAlias = fetchedPost.collectionAlias managedPost.wasDeletedFromServer = false } } } DispatchQueue.main.async { for post in postsToDelete { post.wasDeletedFromServer = true } LocalStorageManager.standard.saveContext() } } catch { self.currentError = AppError.genericError(error.localizedDescription) } } catch WFError.unauthorized { self.currentError = AccountError.genericAuthError self.logout() } catch { self.currentError = LocalStoreError.couldNotFetchPosts("cached") } } func publishHandler(result: Result) { // We're done with the network request. DispatchQueue.main.async { self.isProcessingRequest = false } // ⚠️ NOTE: // The API does not return a collection alias, so we take care not to overwrite the // cached post's collection alias with the 'nil' value from the fetched post. // See: https://github.com/writeas/writefreely-swift/issues/20 do { let fetchedPost = try result.get() // If this is an updated post, check it against postToUpdate. if let updatingPost = self.postToUpdate { importData(from: fetchedPost, into: updatingPost) DispatchQueue.main.async { LocalStorageManager.standard.saveContext() } } else { // Otherwise if it's a newly-published post, find it in the local store. let request = WFAPost.createFetchRequest() let matchBodyPredicate = NSPredicate(format: "body == %@", fetchedPost.body) if let fetchedPostTitle = fetchedPost.title { let matchTitlePredicate = NSPredicate(format: "title == %@", fetchedPostTitle) request.predicate = NSCompoundPredicate( andPredicateWithSubpredicates: [ matchTitlePredicate, matchBodyPredicate ] ) } else { request.predicate = matchBodyPredicate } do { let cachedPostsResults = try LocalStorageManager.standard.container.viewContext.fetch(request) guard let cachedPost = cachedPostsResults.first else { return } importData(from: fetchedPost, into: cachedPost) DispatchQueue.main.async { LocalStorageManager.standard.saveContext() } } catch { self.currentError = LocalStoreError.couldNotFetchPosts("cached") } } } catch { self.currentError = AppError.genericError(error.localizedDescription) } } func updateFromServerHandler(result: Result) { // We're done with the network request. DispatchQueue.main.async { self.isProcessingRequest = false } // ⚠️ NOTE: // The API does not return a collection alias, so we take care not to overwrite the // cached post's collection alias with the 'nil' value from the fetched post. // See: https://github.com/writeas/writefreely-swift/issues/20 do { let fetchedPost = try result.get() guard let cachedPost = self.selectedPost else { return } importData(from: fetchedPost, into: cachedPost) cachedPost.hasNewerRemoteCopy = false DispatchQueue.main.async { LocalStorageManager.standard.saveContext() } } catch { self.currentError = AppError.genericError(error.localizedDescription) } } func movePostHandler(result: Result) { // We're done with the network request. DispatchQueue.main.async { self.isProcessingRequest = false } do { let succeeded = try result.get() if succeeded { if let post = selectedPost { updateFromServer(post: post) } else { return } } } catch { DispatchQueue.main.async { LocalStorageManager.standard.container.viewContext.rollback() } self.currentError = AppError.genericError(error.localizedDescription) } } private func importData(from fetchedPost: WFPost, into cachedPost: WFAPost) { cachedPost.appearance = fetchedPost.appearance cachedPost.body = fetchedPost.body cachedPost.createdDate = fetchedPost.createdDate cachedPost.language = fetchedPost.language cachedPost.postId = fetchedPost.postId cachedPost.rtl = fetchedPost.rtl ?? false cachedPost.slug = fetchedPost.slug cachedPost.status = PostStatus.published.rawValue cachedPost.title = fetchedPost.title ?? "" cachedPost.updatedDate = fetchedPost.updatedDate } } diff --git a/Shared/PostList/PostListModel.swift b/Shared/PostList/PostListModel.swift index db0ff4a..edd545c 100644 --- a/Shared/PostList/PostListModel.swift +++ b/Shared/PostList/PostListModel.swift @@ -1,126 +1,126 @@ import SwiftUI import CoreData class PostListModel: ObservableObject { func remove(_ post: WFAPost) { withAnimation { LocalStorageManager.standard.container.viewContext.delete(post) LocalStorageManager.standard.saveContext() } } - func purgePublishedPosts() { + func purgePublishedPosts() throws { let fetchRequest: NSFetchRequest = NSFetchRequest(entityName: "WFAPost") fetchRequest.predicate = NSPredicate(format: "status != %i", 0) let deleteRequest = NSBatchDeleteRequest(fetchRequest: fetchRequest) do { try LocalStorageManager.standard.container.viewContext.executeAndMergeChanges(using: deleteRequest) } catch { - print("Error: Failed to purge cached posts.") + throw LocalStoreError.couldNotPurgePosts("cached") } } func getBodyPreview(of post: WFAPost) -> String { var elidedPostBody: String = "" // Strip any markdown from the post body. let strippedPostBody = stripMarkdown(from: post.body) // Extract lede from post. elidedPostBody = extractLede(from: strippedPostBody) return elidedPostBody } } private extension PostListModel { func stripMarkdown(from string: String) -> String { var strippedString = string strippedString = stripHeadingOctothorpes(from: strippedString) strippedString = stripImages(from: strippedString, keepAltText: true) return strippedString } func stripHeadingOctothorpes(from string: String) -> String { let newLines = CharacterSet.newlines var processedComponents: [String] = [] let components = string.components(separatedBy: newLines) for component in components { if component.isEmpty { continue } var newString = component while newString.first == "#" { newString.removeFirst() } if newString.hasPrefix(" ") { newString.removeFirst() } processedComponents.append(newString) } let headinglessString = processedComponents.joined(separator: "\n\n") return headinglessString } func stripImages(from string: String, keepAltText: Bool = false) -> String { let pattern = #"!\[[\"]?(.*?)[\"|]?\]\(.*?\)"# var processedComponents: [String] = [] let components = string.components(separatedBy: .newlines) for component in components { if component.isEmpty { continue } var processedString: String = component if keepAltText { let regex = try? NSRegularExpression(pattern: pattern, options: []) if let matches = regex?.matches( in: component, options: [], range: NSRange(location: 0, length: component.utf16.count) ) { for match in matches { if let range = Range(match.range(at: 1), in: component) { processedString = "\(component[range])" } } } } else { let range = component.startIndex.. String { let truncatedString = string.prefix(80) let terminatingPunctuation = ".。?" let terminatingCharacters = CharacterSet(charactersIn: terminatingPunctuation).union(.newlines) var lede: String = "" let sentences = truncatedString.components(separatedBy: terminatingCharacters) if let firstSentence = (sentences.filter { !$0.isEmpty }).first { if truncatedString.count > firstSentence.count { if terminatingPunctuation.contains(truncatedString[firstSentence.endIndex]) { lede = String(truncatedString[...firstSentence.endIndex]) } else { lede = firstSentence } } else if truncatedString.count == firstSentence.count { if string.count > 80 { if let endOfStringIndex = truncatedString.lastIndex(of: " ") { lede = truncatedString[..