diff --git a/Shared/ErrorHandling/ErrorConstants.swift b/Shared/ErrorHandling/ErrorConstants.swift index 467b858..1b66594 100644 --- a/Shared/ErrorHandling/ErrorConstants.swift +++ b/Shared/ErrorHandling/ErrorConstants.swift @@ -1,116 +1,140 @@ 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 } 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: "") } } } // MARK: - Account Errors enum AccountError: Error { case invalidPassword case usernameNotFound case serverNotFound case invalidServerURL case couldNotSaveTokenToKeychain case couldNotFetchTokenFromKeychain case couldNotDeleteTokenFromKeychain 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 .couldNotSaveTokenToKeychain: return NSLocalizedString( "There was a problem trying to save your access token to the device, please try logging in again.", comment: "" ) case .couldNotFetchTokenFromKeychain: return NSLocalizedString( "There was a problem trying to fetch your access token from the device, please try logging in again.", comment: "" ) case .couldNotDeleteTokenFromKeychain: return NSLocalizedString( "There was a problem trying to delete your access token from the device, please try logging out again.", 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: "") + return NSLocalizedString("An unknown error occurred while trying to login.", comment: "") } } } // MARK: - Local Store Errors enum LocalStoreError: Error { + case couldNotFetchCollections case couldNotFetchPosts(String) + case couldNotPurgePublishedPosts } extension LocalStoreError: LocalizedError { public var errorDescription: String? { switch self { + 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: "") + return NSLocalizedString("Failed to fetch posts from local store.", comment: "") } else { - return NSLocalizedString("Failed to fetch \(postFilter) posts from local store", comment: "") + return NSLocalizedString("Failed to fetch \(postFilter) posts from local store.", comment: "") } + case .couldNotPurgePublishedPosts: + return NSLocalizedString("Failed to purge published posts from local store.", comment: "") + } + } +} + +// MARK: - Application Errors + +enum AppError: Error { + case couldNotGetLoggedInClient + case couldNotGetPostId +} + +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: "") } } } diff --git a/Shared/Extensions/WriteFreelyModel+API.swift b/Shared/Extensions/WriteFreelyModel+API.swift index 01edabc..55282b1 100644 --- a/Shared/Extensions/WriteFreelyModel+API.swift +++ b/Shared/Extensions/WriteFreelyModel+API.swift @@ -1,164 +1,173 @@ import Foundation import WriteFreely extension WriteFreelyModel { func login(to server: URL, as username: String, password: String) { if !hasNetworkConnection { isPresentingNetworkErrorAlert = true return } let secureProtocolPrefix = "https://" let insecureProtocolPrefix = "http://" var serverString = server.absoluteString // If there's neither an http or https prefix, prepend "https://" to the server string. if !(serverString.hasPrefix(secureProtocolPrefix) || serverString.hasPrefix(insecureProtocolPrefix)) { serverString = secureProtocolPrefix + serverString } // If the server string is prefixed with http, upgrade to https before attempting to login. if serverString.hasPrefix(insecureProtocolPrefix) { serverString = serverString.replacingOccurrences(of: insecureProtocolPrefix, with: secureProtocolPrefix) } isLoggingIn = true var serverURL = URL(string: serverString)! if !serverURL.path.isEmpty { serverURL.deleteLastPathComponent() } account.server = serverURL.absoluteString client = WFClient(for: serverURL) client?.login(username: username, password: password, completion: loginHandler) } func logout() { if !hasNetworkConnection { DispatchQueue.main.async { self.isPresentingNetworkErrorAlert = true } return } guard let loggedInClient = client else { do { try purgeTokenFromKeychain(username: account.username, server: account.server) account.logout() } catch { - fatalError("Failed to log out persisted state") + self.currentError = KeychainError.couldNotPurgeAccessToken } return } loggedInClient.logout(completion: logoutHandler) } func fetchUserCollections() { if !hasNetworkConnection { DispatchQueue.main.async { self.isPresentingNetworkErrorAlert = true } return } guard let loggedInClient = client else { - fatalError("Could not get logged in client") + self.currentError = AppError.couldNotGetLoggedInClient + return } // We're starting the network request. DispatchQueue.main.async { self.isProcessingRequest = true } loggedInClient.getUserCollections(completion: fetchUserCollectionsHandler) } func fetchUserPosts() { if !hasNetworkConnection { DispatchQueue.main.async { self.isPresentingNetworkErrorAlert = true } return } guard let loggedInClient = client else { - fatalError("Could not get logged in client") + self.currentError = AppError.couldNotGetLoggedInClient + return } // We're starting the network request. DispatchQueue.main.async { self.isProcessingRequest = true } loggedInClient.getPosts(completion: fetchUserPostsHandler) } func publish(post: WFAPost) { postToUpdate = nil if !hasNetworkConnection { DispatchQueue.main.async { self.isPresentingNetworkErrorAlert = true } return } guard let loggedInClient = client else { - fatalError("Could not get logged in client") + self.currentError = AppError.couldNotGetLoggedInClient + return } // We're starting the network request. DispatchQueue.main.async { self.isProcessingRequest = true } if post.language == nil { if let languageCode = Locale.current.languageCode { post.language = languageCode post.rtl = Locale.characterDirection(forLanguage: languageCode) == .rightToLeft } } var wfPost = WFPost( body: post.body, title: post.title.isEmpty ? "" : post.title, appearance: post.appearance, language: post.language, rtl: post.rtl, createdDate: post.status == PostStatus.local.rawValue ? Date() : post.createdDate ) if let existingPostId = post.postId { // This is an existing post. postToUpdate = post wfPost.postId = post.postId loggedInClient.updatePost( postId: existingPostId, updatedPost: wfPost, completion: publishHandler ) } else { // This is a new local draft. loggedInClient.createPost( post: wfPost, in: post.collectionAlias, completion: publishHandler ) } } func updateFromServer(post: WFAPost) { if !hasNetworkConnection { DispatchQueue.main.async { self.isPresentingNetworkErrorAlert = true } return } guard let loggedInClient = client else { - fatalError("Could not get logged in client") + self.currentError = AppError.couldNotGetLoggedInClient + return } guard let postId = post.postId else { - fatalError("Could not get post ID") + self.currentError = AppError.couldNotGetPostId + return } // We're starting the network request. DispatchQueue.main.async { self.selectedPost = post self.isProcessingRequest = true } loggedInClient.getPost(byId: postId, completion: updateFromServerHandler) } func move(post: WFAPost, from oldCollection: WFACollection?, to newCollection: WFACollection?) { if !hasNetworkConnection { DispatchQueue.main.async { self.isPresentingNetworkErrorAlert = true } return } - guard let loggedInClient = client, - let postId = post.postId else { - fatalError("Could not get post ID") - } + guard let loggedInClient = client else { + self.currentError = AppError.couldNotGetLoggedInClient + return + } + guard let postId = post.postId else { + self.currentError = AppError.couldNotGetPostId + return + } // We're starting the network request. DispatchQueue.main.async { self.isProcessingRequest = true } selectedPost = post post.collectionAlias = newCollection?.alias loggedInClient.movePost(postId: postId, to: newCollection?.alias, completion: movePostHandler) } } diff --git a/Shared/PostCollection/CollectionListModel.swift b/Shared/PostCollection/CollectionListModel.swift index 8beae45..b2e6be3 100644 --- a/Shared/PostCollection/CollectionListModel.swift +++ b/Shared/PostCollection/CollectionListModel.swift @@ -1,40 +1,40 @@ import SwiftUI import CoreData class CollectionListModel: NSObject, ObservableObject { @Published var list: [WFACollection] = [] private let collectionsController: NSFetchedResultsController - init(managedObjectContext: NSManagedObjectContext) { + init(managedObjectContext: NSManagedObjectContext) throws { collectionsController = NSFetchedResultsController(fetchRequest: WFACollection.collectionsFetchRequest, managedObjectContext: managedObjectContext, sectionNameKeyPath: nil, cacheName: nil) super.init() collectionsController.delegate = self do { try collectionsController.performFetch() list = collectionsController.fetchedObjects ?? [] } catch { - fatalError("Failed to fetch collections!") + throw LocalStoreError.couldNotFetchCollections } } } extension CollectionListModel: NSFetchedResultsControllerDelegate { func controllerDidChangeContent(_ controller: NSFetchedResultsController) { guard let collections = controller.fetchedObjects as? [WFACollection] else { return } self.list = collections } } extension WFACollection { static var collectionsFetchRequest: NSFetchRequest { let request: NSFetchRequest = WFACollection.createFetchRequest() request.sortDescriptors = [NSSortDescriptor(keyPath: \WFACollection.title, ascending: true)] return request } } diff --git a/Shared/PostList/PostListModel.swift b/Shared/PostList/PostListModel.swift index 4cf62b0..86c6b9e 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 { - fatalError("Error: Failed to purge cached posts.") + throw LocalStoreError.couldNotPurgePublishedPosts } } 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[.. SchemeUserState ActionExtension-iOS.xcscheme_^#shared#^_ orderHint - 0 + 1 WriteFreely-MultiPlatform (iOS).xcscheme_^#shared#^_ orderHint - 1 + 2 WriteFreely-MultiPlatform (macOS).xcscheme_^#shared#^_ orderHint - 2 + 0