diff --git a/Shared/Account/AccountLogoutView.swift b/Shared/Account/AccountLogoutView.swift index 22df2da..5f087a8 100644 --- a/Shared/Account/AccountLogoutView.swift +++ b/Shared/Account/AccountLogoutView.swift @@ -1,80 +1,80 @@ import SwiftUI struct AccountLogoutView: View { @EnvironmentObject var model: WriteFreelyModel @State private var isPresentingLogoutConfirmation: Bool = false @State private var editedPostsWarningString: String = "" var body: some View { #if os(iOS) VStack { Spacer() VStack { Text("Logged in as \(model.account.username)") Text("on \(model.account.server)") } Spacer() Button(action: logoutHandler, label: { Text("Log Out") }) } .actionSheet(isPresented: $isPresentingLogoutConfirmation, content: { ActionSheet( title: Text("Log Out?"), message: Text("\(editedPostsWarningString)You won't lose any local posts. Are you sure?"), buttons: [ .destructive(Text("Log Out"), action: { model.logout() }), .cancel() ] ) }) #else VStack { Spacer() VStack { Text("Logged in as \(model.account.username)") Text("on \(model.account.server)") } Spacer() Button(action: logoutHandler, label: { Text("Log Out") }) } .alert(isPresented: $isPresentingLogoutConfirmation) { Alert( title: Text("Log Out?"), message: Text("\(editedPostsWarningString)You won't lose any local posts. Are you sure?"), primaryButton: .cancel(Text("Cancel"), action: { self.isPresentingLogoutConfirmation = false }), secondaryButton: .destructive(Text("Log Out"), action: model.logout ) ) } #endif } func logoutHandler() { let request = WFAPost.createFetchRequest() request.predicate = NSPredicate(format: "status == %i", 1) do { let editedPosts = try LocalStorageManager.standard.container.viewContext.fetch(request) if editedPosts.count == 1 { editedPostsWarningString = "You'll lose unpublished changes to \(editedPosts.count) edited post. " } if editedPosts.count > 1 { editedPostsWarningString = "You'll lose unpublished changes to \(editedPosts.count) edited posts. " } } catch { - print("Error: failed to fetch cached posts") + fatalError("Error: failed to fetch cached posts") } self.isPresentingLogoutConfirmation = true } } struct AccountLogoutView_Previews: PreviewProvider { static var previews: some View { AccountLogoutView() .environmentObject(WriteFreelyModel()) } } diff --git a/Shared/Extensions/WriteFreelyModel+API.swift b/Shared/Extensions/WriteFreelyModel+API.swift index 939cf76..01edabc 100644 --- a/Shared/Extensions/WriteFreelyModel+API.swift +++ b/Shared/Extensions/WriteFreelyModel+API.swift @@ -1,152 +1,164 @@ 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") } return } loggedInClient.logout(completion: logoutHandler) } func fetchUserCollections() { if !hasNetworkConnection { DispatchQueue.main.async { self.isPresentingNetworkErrorAlert = true } return } - guard let loggedInClient = client else { return } + guard let loggedInClient = client else { + fatalError("Could not get logged in client") + } // 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 { return } + guard let loggedInClient = client else { + fatalError("Could not get logged in client") + } // 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 { return } + guard let loggedInClient = client else { + fatalError("Could not get logged in client") + } // 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 { return } - guard let postId = post.postId else { return } + guard let loggedInClient = client else { + fatalError("Could not get logged in client") + } + guard let postId = post.postId else { + fatalError("Could not get post ID") + } // 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 { return } + let postId = post.postId else { + fatalError("Could not get post ID") + } // 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/Extensions/WriteFreelyModel+APIHandlers.swift b/Shared/Extensions/WriteFreelyModel+APIHandlers.swift index 804dd41..8b1e5d0 100644 --- a/Shared/Extensions/WriteFreelyModel+APIHandlers.swift +++ b/Shared/Extensions/WriteFreelyModel+APIHandlers.swift @@ -1,278 +1,284 @@ 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 { DispatchQueue.main.async { self.loginErrorMessage = "There was a problem storing your access token to the Keychain." self.isPresentingLoginErrorAlert = true } } } catch WFError.notFound { DispatchQueue.main.async { self.loginErrorMessage = AccountError.usernameNotFound.localizedDescription self.isPresentingLoginErrorAlert = true } } catch WFError.unauthorized { DispatchQueue.main.async { self.loginErrorMessage = AccountError.invalidPassword.localizedDescription self.isPresentingLoginErrorAlert = true } } catch { if (error as NSError).domain == NSURLErrorDomain, (error as NSError).code == -1003 { DispatchQueue.main.async { self.loginErrorMessage = AccountError.serverNotFound.localizedDescription self.isPresentingLoginErrorAlert = true } } else { DispatchQueue.main.async { self.loginErrorMessage = error.localizedDescription self.isPresentingLoginErrorAlert = true } } } } 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() } } catch { - print("Something went wrong purging the token from the Keychain.") + fatalError("Something went wrong purging the token from the Keychain.") } } 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() } } catch { - print("Something went wrong purging the token from the Keychain.") + fatalError("Something went wrong purging the token from the Keychain.") } } 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 { DispatchQueue.main.async { self.loginErrorMessage = "Something went wrong, please try logging in again." self.isPresentingLoginErrorAlert = true } self.logout() } catch { - print(error) + fatalError(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 { print("Error: could not determine which copy of post is newer") } + } else { + fatalError("Error: 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 { - print(error) + fatalError(error.localizedDescription) } } catch WFError.unauthorized { DispatchQueue.main.async { self.loginErrorMessage = "Something went wrong, please try logging in again." self.isPresentingLoginErrorAlert = true } self.logout() } catch { - print("Error: Failed to fetch cached posts") + fatalError("Error: Failed to fetch cached posts") } } 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 } + guard let cachedPost = cachedPostsResults.first else { + fatalError("Could not get cached post from results") + } importData(from: fetchedPost, into: cachedPost) DispatchQueue.main.async { LocalStorageManager.standard.saveContext() } } catch { - print("Error: Failed to fetch cached posts") + fatalError("Error: Failed to fetch cached posts") } } } catch { - print(error) + fatalError(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 } + guard let cachedPost = self.selectedPost else { + fatalError("Could not get cached post") + } importData(from: fetchedPost, into: cachedPost) cachedPost.hasNewerRemoteCopy = false DispatchQueue.main.async { LocalStorageManager.standard.saveContext() } } catch { - print(error) + fatalError(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 + fatalError("Could not update post from server") } } } catch { DispatchQueue.main.async { LocalStorageManager.standard.container.viewContext.rollback() } - print(error) + fatalError(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/LocalStorageManager.swift b/Shared/LocalStorageManager.swift index b644faf..409f8c3 100644 --- a/Shared/LocalStorageManager.swift +++ b/Shared/LocalStorageManager.swift @@ -1,123 +1,123 @@ import CoreData #if os(iOS) import UIKit #elseif os(macOS) import AppKit #endif final class LocalStorageManager { public static var standard = LocalStorageManager() public let container: NSPersistentContainer private let containerName = "LocalStorageModel" private init() { container = NSPersistentContainer(name: containerName) setupStore(in: container) registerObservers() } func saveContext() { if container.viewContext.hasChanges { do { try container.viewContext.save() } catch { - print("Error saving context: \(error)") + fatalError("Error saving context: \(error)") } } } func purgeUserCollections() { let fetchRequest: NSFetchRequest = NSFetchRequest(entityName: "WFACollection") let deleteRequest = NSBatchDeleteRequest(fetchRequest: fetchRequest) do { try container.viewContext.executeAndMergeChanges(using: deleteRequest) } catch { - print("Error: Failed to purge cached collections.") + fatalError("Error: Failed to purge cached collections.") } } } private extension LocalStorageManager { var oldStoreURL: URL { let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! return appSupport.appendingPathComponent("LocalStorageModel.sqlite") } var sharedStoreURL: URL { let id = "group.com.abunchtell.writefreely" let groupContainer = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: id)! return groupContainer.appendingPathComponent("LocalStorageModel.sqlite") } func setupStore(in container: NSPersistentContainer) { if !FileManager.default.fileExists(atPath: oldStoreURL.path) { container.persistentStoreDescriptions.first!.url = sharedStoreURL } container.loadPersistentStores { _, error in if let error = error { fatalError("Core Data store failed to load with error: \(error)") } } migrateStore(for: container) container.viewContext.automaticallyMergesChangesFromParent = true container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy } func migrateStore(for container: NSPersistentContainer) { // Check if the shared store exists before attempting a migration — for example, in case we've already attempted // and successfully completed a migration, but the deletion of the old store failed for some reason. guard !FileManager.default.fileExists(atPath: sharedStoreURL.path) else { return } let coordinator = container.persistentStoreCoordinator // Get a reference to the old store. guard let oldStore = coordinator.persistentStore(for: oldStoreURL) else { return } // Attempt to migrate the old store over to the shared store URL. do { try coordinator.migratePersistentStore(oldStore, to: sharedStoreURL, options: nil, withType: NSSQLiteStoreType) } catch { fatalError("Something went wrong migrating the store: \(error)") } // Attempt to delete the old store. do { try FileManager.default.removeItem(at: oldStoreURL) } catch { fatalError("Something went wrong while deleting the old store: \(error)") } } func registerObservers() { let center = NotificationCenter.default #if os(iOS) let notification = UIApplication.willResignActiveNotification #elseif os(macOS) let notification = NSApplication.willResignActiveNotification #endif // We don't need to worry about removing this observer because we're targeting iOS 9+ / macOS 10.11+; the // system will clean this up the next time it would be posted to. // See: https://developer.apple.com/documentation/foundation/notificationcenter/1413994-removeobserver // And: https://developer.apple.com/documentation/foundation/notificationcenter/1407263-removeobserver // swiftlint:disable:next discarded_notification_center_observer center.addObserver(forName: notification, object: nil, queue: nil, using: self.saveContextOnResignActive) } func saveContextOnResignActive(_ notification: Notification) { saveContext() } } diff --git a/Shared/PostCollection/CollectionListModel.swift b/Shared/PostCollection/CollectionListModel.swift index 5e107bb..8beae45 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) { collectionsController = NSFetchedResultsController(fetchRequest: WFACollection.collectionsFetchRequest, managedObjectContext: managedObjectContext, sectionNameKeyPath: nil, cacheName: nil) super.init() collectionsController.delegate = self do { try collectionsController.performFetch() list = collectionsController.fetchedObjects ?? [] } catch { - print("Failed to fetch collections!") + fatalError("Failed to fetch collections!") } } } 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 db0ff4a..4cf62b0 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() { 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.") + fatalError("Error: Failed to purge cached posts.") } } 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[..