diff --git a/Shared/Account/AccountLogoutView.swift b/Shared/Account/AccountLogoutView.swift index 66aa1b1..22df2da 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.persistentContainer.viewContext.fetch(request) + 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") } self.isPresentingLogoutConfirmation = true } } struct AccountLogoutView_Previews: PreviewProvider { static var previews: some View { AccountLogoutView() .environmentObject(WriteFreelyModel()) } } diff --git a/Shared/Extensions/WriteFreelyModel+APIHandlers.swift b/Shared/Extensions/WriteFreelyModel+APIHandlers.swift index 029e675..9a7447e 100644 --- a/Shared/Extensions/WriteFreelyModel+APIHandlers.swift +++ b/Shared/Extensions/WriteFreelyModel+APIHandlers.swift @@ -1,301 +1,301 @@ 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.") } } 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.") } } 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.persistentContainer.viewContext) + 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) } } 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.persistentContainer.viewContext.fetch(request) + 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") } postsToDelete.removeAll(where: { $0.postId == fetchedPost.postId }) } } else { DispatchQueue.main.async { - let managedPost = WFAPost(context: LocalStorageManager.standard.persistentContainer.viewContext) + let managedPost = WFAPost(context: LocalStorageManager.standard.container.viewContext) managedPost.postId = fetchedPost.postId managedPost.slug = fetchedPost.slug managedPost.appearance = fetchedPost.appearance managedPost.language = fetchedPost.language managedPost.rtl = fetchedPost.rtl ?? false managedPost.createdDate = fetchedPost.createdDate managedPost.updatedDate = fetchedPost.updatedDate managedPost.title = fetchedPost.title ?? "" managedPost.body = fetchedPost.body managedPost.collectionAlias = fetchedPost.collectionAlias managedPost.status = PostStatus.published.rawValue managedPost.wasDeletedFromServer = false } } } DispatchQueue.main.async { for post in postsToDelete { post.wasDeletedFromServer = true } LocalStorageManager.standard.saveContext() } } catch { print(error) } } 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") } } 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 { updatingPost.appearance = fetchedPost.appearance updatingPost.body = fetchedPost.body updatingPost.createdDate = fetchedPost.createdDate updatingPost.language = fetchedPost.language updatingPost.postId = fetchedPost.postId updatingPost.rtl = fetchedPost.rtl ?? false updatingPost.slug = fetchedPost.slug updatingPost.status = PostStatus.published.rawValue updatingPost.title = fetchedPost.title ?? "" updatingPost.updatedDate = fetchedPost.updatedDate 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.persistentContainer.viewContext.fetch(request) + let cachedPostsResults = try LocalStorageManager.standard.container.viewContext.fetch(request) guard let cachedPost = cachedPostsResults.first else { return } 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 DispatchQueue.main.async { LocalStorageManager.standard.saveContext() } } catch { print("Error: Failed to fetch cached posts") } } } catch { print(error) } } 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 } 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 cachedPost.hasNewerRemoteCopy = false DispatchQueue.main.async { LocalStorageManager.standard.saveContext() } } catch { print(error) } } 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.persistentContainer.viewContext.rollback() + LocalStorageManager.standard.container.viewContext.rollback() } print(error) } } } diff --git a/Shared/LocalStorageManager.swift b/Shared/LocalStorageManager.swift index d5ebe42..739ae8d 100644 --- a/Shared/LocalStorageManager.swift +++ b/Shared/LocalStorageManager.swift @@ -1,65 +1,65 @@ import CoreData #if os(iOS) import UIKit #elseif os(macOS) import AppKit #endif final class LocalStorageManager { public static var standard = LocalStorageManager() - public let persistentContainer: NSPersistentContainer + public let container: NSPersistentContainer init() { // Set up the persistent container. - persistentContainer = NSPersistentContainer(name: "LocalStorageModel") - persistentContainer.loadPersistentStores { description, error in + container = NSPersistentContainer(name: "LocalStorageModel") + container.loadPersistentStores { description, error in if let error = error { fatalError("Core Data store failed to load with error: \(error)") } } - persistentContainer.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy + container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy 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 saveContext() { - if persistentContainer.viewContext.hasChanges { + if container.viewContext.hasChanges { do { - try persistentContainer.viewContext.save() + try container.viewContext.save() } catch { print("Error saving context: \(error)") } } } func purgeUserCollections() { let fetchRequest: NSFetchRequest = NSFetchRequest(entityName: "WFACollection") let deleteRequest = NSBatchDeleteRequest(fetchRequest: fetchRequest) do { - try persistentContainer.viewContext.executeAndMergeChanges(using: deleteRequest) + try container.viewContext.executeAndMergeChanges(using: deleteRequest) } catch { print("Error: Failed to purge cached collections.") } } } private extension LocalStorageManager { func saveContextOnResignActive(_ notification: Notification) { saveContext() } } diff --git a/Shared/Navigation/ContentView.swift b/Shared/Navigation/ContentView.swift index 1e320f6..7fe7566 100644 --- a/Shared/Navigation/ContentView.swift +++ b/Shared/Navigation/ContentView.swift @@ -1,71 +1,71 @@ import SwiftUI struct ContentView: View { @EnvironmentObject var model: WriteFreelyModel var body: some View { NavigationView { #if os(macOS) CollectionListView() .toolbar { Button( action: { NSApp.keyWindow?.contentViewController?.tryToPerform( #selector(NSSplitViewController.toggleSidebar(_:)), with: nil ) }, label: { Image(systemName: "sidebar.left") } ) .help("Toggle the sidebar's visibility.") Spacer() Button(action: { withAnimation { // Un-set the currently selected post self.model.selectedPost = nil } // Create the new-post managed object let managedPost = model.editor.generateNewLocalPost(withFont: model.preferences.font) withAnimation { DispatchQueue.main.asyncAfter(deadline: .now()) { // Load the new post in the editor self.model.selectedPost = managedPost } } }, label: { Image(systemName: "square.and.pencil") }) .help("Create a new local draft.") } #else CollectionListView(selectedCollection: model.selectedCollection) #endif #if os(macOS) ZStack { PostListView() if model.isProcessingRequest { ZStack { Color(NSColor.controlBackgroundColor).opacity(0.75) ProgressView() } } } #else PostListView(selectedCollection: model.selectedCollection, showAllPosts: model.showAllPosts) #endif Text("Select a post, or create a new local draft.") .foregroundColor(.secondary) } .environmentObject(model) } } struct ContentView_Previews: PreviewProvider { static var previews: some View { - let context = LocalStorageManager.standard.persistentContainer.viewContext + let context = LocalStorageManager.standard.container.viewContext let model = WriteFreelyModel() return ContentView() .environment(\.managedObjectContext, context) .environmentObject(model) } } diff --git a/Shared/PostCollection/CollectionListView.swift b/Shared/PostCollection/CollectionListView.swift index bf0595c..da5dcda 100644 --- a/Shared/PostCollection/CollectionListView.swift +++ b/Shared/PostCollection/CollectionListView.swift @@ -1,53 +1,53 @@ import SwiftUI struct CollectionListView: View { @EnvironmentObject var model: WriteFreelyModel - @ObservedObject var collections = CollectionListModel(managedObjectContext: LocalStorageManager.standard.persistentContainer.viewContext) + @ObservedObject var collections = CollectionListModel(managedObjectContext: LocalStorageManager.standard.container.viewContext) @State var selectedCollection: WFACollection? var body: some View { List(selection: $selectedCollection) { if model.account.isLoggedIn { NavigationLink("All Posts", destination: PostListView(selectedCollection: nil, showAllPosts: true)) NavigationLink("Drafts", destination: PostListView(selectedCollection: nil, showAllPosts: false)) Section(header: Text("Your Blogs")) { ForEach(collections.list, id: \.self) { collection in NavigationLink(destination: PostListView(selectedCollection: collection, showAllPosts: false), tag: collection, selection: $selectedCollection, label: { Text("\(collection.title)") }) } } } else { NavigationLink(destination: PostListView(selectedCollection: nil, showAllPosts: false)) { Text("Drafts") } } } .navigationTitle( model.account.isLoggedIn ? "\(URL(string: model.account.server)?.host ?? "WriteFreely")" : "WriteFreely" ) .listStyle(SidebarListStyle()) .onChange(of: model.selectedCollection) { collection in if collection != model.editor.fetchSelectedCollectionFromAppStorage() { self.model.editor.selectedCollectionURL = collection?.objectID.uriRepresentation() } } .onChange(of: model.showAllPosts) { value in if value != model.editor.showAllPostsFlag { self.model.editor.showAllPostsFlag = model.showAllPosts } } } } struct CollectionListView_LoggedOutPreviews: PreviewProvider { static var previews: some View { - let context = LocalStorageManager.standard.persistentContainer.viewContext + let context = LocalStorageManager.standard.container.viewContext let model = WriteFreelyModel() return CollectionListView() .environment(\.managedObjectContext, context) .environmentObject(model) } } diff --git a/Shared/PostEditor/PostEditorModel.swift b/Shared/PostEditor/PostEditorModel.swift index 9c2d2d3..6970949 100644 --- a/Shared/PostEditor/PostEditorModel.swift +++ b/Shared/PostEditor/PostEditorModel.swift @@ -1,63 +1,63 @@ import SwiftUI import CoreData enum PostAppearance: String { case sans = "OpenSans-Regular" case mono = "Hack-Regular" case serif = "Lora-Regular" } struct PostEditorModel { @AppStorage("showAllPostsFlag") var showAllPostsFlag: Bool = false @AppStorage("selectedCollectionURL") var selectedCollectionURL: URL? @AppStorage("lastDraftURL") var lastDraftURL: URL? func saveLastDraft(_ post: WFAPost) { self.lastDraftURL = post.status != PostStatus.published.rawValue ? post.objectID.uriRepresentation() : nil } func clearLastDraft() { self.lastDraftURL = nil } func fetchLastDraftFromAppStorage() -> WFAPost? { guard let postURL = lastDraftURL else { return nil } guard let post = fetchManagedObject(from: postURL) as? WFAPost else { return nil } return post } func generateNewLocalPost(withFont appearance: Int) -> WFAPost { - let managedPost = WFAPost(context: LocalStorageManager.standard.persistentContainer.viewContext) + let managedPost = WFAPost(context: LocalStorageManager.standard.container.viewContext) managedPost.createdDate = Date() managedPost.title = "" managedPost.body = "" managedPost.status = PostStatus.local.rawValue managedPost.collectionAlias = WriteFreelyModel.shared.selectedCollection?.alias switch appearance { case 1: managedPost.appearance = "sans" case 2: managedPost.appearance = "wrap" default: managedPost.appearance = "serif" } if let languageCode = Locale.current.languageCode { managedPost.language = languageCode managedPost.rtl = Locale.characterDirection(forLanguage: languageCode) == .rightToLeft } return managedPost } func fetchSelectedCollectionFromAppStorage() -> WFACollection? { guard let collectionURL = selectedCollectionURL else { return nil } guard let collection = fetchManagedObject(from: collectionURL) as? WFACollection else { return nil } return collection } private func fetchManagedObject(from objectURL: URL) -> NSManagedObject? { - let coordinator = LocalStorageManager.standard.persistentContainer.persistentStoreCoordinator + let coordinator = LocalStorageManager.standard.container.persistentStoreCoordinator guard let managedObjectID = coordinator.managedObjectID(forURIRepresentation: objectURL) else { return nil } - let object = LocalStorageManager.standard.persistentContainer.viewContext.object(with: managedObjectID) + let object = LocalStorageManager.standard.container.viewContext.object(with: managedObjectID) return object } } diff --git a/Shared/PostEditor/PostEditorStatusToolbarView.swift b/Shared/PostEditor/PostEditorStatusToolbarView.swift index 7b2e1bb..be49544 100644 --- a/Shared/PostEditor/PostEditorStatusToolbarView.swift +++ b/Shared/PostEditor/PostEditorStatusToolbarView.swift @@ -1,102 +1,102 @@ import SwiftUI struct PostEditorStatusToolbarView: View { @EnvironmentObject var model: WriteFreelyModel @ObservedObject var post: WFAPost var body: some View { if post.hasNewerRemoteCopy { #if os(iOS) PostStatusBadgeView(post: post) #else HStack { HStack { Text("⚠️ Newer copy on server. Replace local copy?") .font(.callout) .foregroundColor(.secondary) Button(action: { model.updateFromServer(post: post) }, label: { Image(systemName: "square.and.arrow.down") }) .accessibilityLabel(Text("Update post")) .accessibilityHint(Text("Replace this post with the server version")) } .padding(.horizontal) .background(Color.primary.opacity(0.1)) .clipShape(Capsule()) .padding(.trailing) PostStatusBadgeView(post: post) } #endif } else if post.wasDeletedFromServer && post.status != PostStatus.local.rawValue { #if os(iOS) PostStatusBadgeView(post: post) #else HStack { HStack { Text("⚠️ Post deleted from server. Delete local copy?") .font(.callout) .foregroundColor(.secondary) Button(action: { model.selectedPost = nil DispatchQueue.main.async { model.posts.remove(post) } }, label: { Image(systemName: "trash") }) .accessibilityLabel(Text("Delete")) .accessibilityHint(Text("Delete this post from your Mac")) } .padding(.horizontal) .background(Color.primary.opacity(0.1)) .clipShape(Capsule()) .padding(.trailing) PostStatusBadgeView(post: post) } #endif } else { PostStatusBadgeView(post: post) } } } struct PESTView_StandardPreviews: PreviewProvider { static var previews: some View { - let context = LocalStorageManager.standard.persistentContainer.viewContext + let context = LocalStorageManager.standard.container.viewContext let model = WriteFreelyModel() let testPost = WFAPost(context: context) testPost.status = PostStatus.published.rawValue return PostEditorStatusToolbarView(post: testPost) .environmentObject(model) } } struct PESTView_OutdatedLocalCopyPreviews: PreviewProvider { static var previews: some View { - let context = LocalStorageManager.standard.persistentContainer.viewContext + let context = LocalStorageManager.standard.container.viewContext let model = WriteFreelyModel() let updatedPost = WFAPost(context: context) updatedPost.status = PostStatus.published.rawValue updatedPost.hasNewerRemoteCopy = true return PostEditorStatusToolbarView(post: updatedPost) .environmentObject(model) } } struct PESTView_DeletedRemoteCopyPreviews: PreviewProvider { static var previews: some View { - let context = LocalStorageManager.standard.persistentContainer.viewContext + let context = LocalStorageManager.standard.container.viewContext let model = WriteFreelyModel() let deletedPost = WFAPost(context: context) deletedPost.status = PostStatus.published.rawValue deletedPost.wasDeletedFromServer = true return PostEditorStatusToolbarView(post: deletedPost) .environmentObject(model) } } diff --git a/Shared/PostList/PostCellView.swift b/Shared/PostList/PostCellView.swift index f7e430a..6787b00 100644 --- a/Shared/PostList/PostCellView.swift +++ b/Shared/PostList/PostCellView.swift @@ -1,86 +1,86 @@ import SwiftUI struct PostCellView: View { @EnvironmentObject var model: WriteFreelyModel @ObservedObject var post: WFAPost var collectionName: String? static let createdDateFormat: DateFormatter = { let formatter = DateFormatter() formatter.locale = Locale.current formatter.dateStyle = .long formatter.timeStyle = .short return formatter }() var titleText: String { if post.title.isEmpty { return model.posts.getBodyPreview(of: post) } return post.title } var body: some View { HStack { VStack(alignment: .leading) { if let collectionName = collectionName { Text(collectionName) .font(.caption) .foregroundColor(.secondary) .padding(EdgeInsets(top: 3, leading: 4, bottom: 3, trailing: 4)) .overlay(RoundedRectangle(cornerRadius: 2).stroke(Color.secondary, lineWidth: 1)) } Text(titleText) .font(.headline) Text(post.createdDate ?? Date(), formatter: Self.createdDateFormat) .font(.caption) .foregroundColor(.secondary) .padding(.top, -3) } Spacer() PostStatusBadgeView(post: post) } .padding(5) } } struct PostCell_AllPostsPreviews: PreviewProvider { static var previews: some View { - let context = LocalStorageManager.standard.persistentContainer.viewContext + let context = LocalStorageManager.standard.container.viewContext let testPost = WFAPost(context: context) testPost.title = "Test Post Title" testPost.body = "Here's some cool sample body text." testPost.createdDate = Date() return PostCellView(post: testPost, collectionName: "My Cool Blog") .environment(\.managedObjectContext, context) } } struct PostCell_NormalPreviews: PreviewProvider { static var previews: some View { - let context = LocalStorageManager.standard.persistentContainer.viewContext + let context = LocalStorageManager.standard.container.viewContext let testPost = WFAPost(context: context) testPost.title = "Test Post Title" testPost.body = "Here's some cool sample body text." testPost.collectionAlias = "My Cool Blog" testPost.createdDate = Date() return PostCellView(post: testPost) .environment(\.managedObjectContext, context) } } struct PostCell_NoTitlePreviews: PreviewProvider { static var previews: some View { - let context = LocalStorageManager.standard.persistentContainer.viewContext + let context = LocalStorageManager.standard.container.viewContext let testPost = WFAPost(context: context) testPost.title = "" testPost.body = "Here's some cool sample body text." testPost.collectionAlias = "My Cool Blog" testPost.createdDate = Date() return PostCellView(post: testPost) .environment(\.managedObjectContext, context) } } diff --git a/Shared/PostList/PostListModel.swift b/Shared/PostList/PostListModel.swift index 69a0cf8..db0ff4a 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.persistentContainer.viewContext.delete(post) + 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.persistentContainer.viewContext.executeAndMergeChanges(using: deleteRequest) + try LocalStorageManager.standard.container.viewContext.executeAndMergeChanges(using: deleteRequest) } catch { print("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[.. (String, Color) { var badgeLabel: String var badgeColor: Color switch status { case .local: badgeLabel = "local" badgeColor = Color(red: 0.75, green: 0.5, blue: 0.85, opacity: 1.0) case .edited: badgeLabel = "edited" badgeColor = Color(red: 0.75, green: 0.7, blue: 0.1, opacity: 1.0) case .published: badgeLabel = "published" badgeColor = .gray } return (badgeLabel, badgeColor) } } struct PostStatusBadge_LocalDraftPreviews: PreviewProvider { static var previews: some View { - let context = LocalStorageManager.standard.persistentContainer.viewContext + let context = LocalStorageManager.standard.container.viewContext let testPost = WFAPost(context: context) testPost.status = PostStatus.local.rawValue return PostStatusBadgeView(post: testPost) .environment(\.managedObjectContext, context) } } struct PostStatusBadge_EditedPreviews: PreviewProvider { static var previews: some View { - let context = LocalStorageManager.standard.persistentContainer.viewContext + let context = LocalStorageManager.standard.container.viewContext let testPost = WFAPost(context: context) testPost.status = PostStatus.edited.rawValue return PostStatusBadgeView(post: testPost) .environment(\.managedObjectContext, context) } } struct PostStatusBadge_PublishedPreviews: PreviewProvider { static var previews: some View { - let context = LocalStorageManager.standard.persistentContainer.viewContext + let context = LocalStorageManager.standard.container.viewContext let testPost = WFAPost(context: context) testPost.status = PostStatus.published.rawValue return PostStatusBadgeView(post: testPost) .environment(\.managedObjectContext, context) } } diff --git a/Shared/WriteFreely_MultiPlatformApp.swift b/Shared/WriteFreely_MultiPlatformApp.swift index bd51ae3..3ba051d 100644 --- a/Shared/WriteFreely_MultiPlatformApp.swift +++ b/Shared/WriteFreely_MultiPlatformApp.swift @@ -1,155 +1,155 @@ import SwiftUI #if os(macOS) import Sparkle #endif @main struct CheckForDebugModifier { static func main() { #if os(macOS) if NSEvent.modifierFlags.contains(.shift) { // Clear the launch-to-last-draft values to load a new draft. UserDefaults.standard.setValue(false, forKey: "showAllPostsFlag") UserDefaults.standard.setValue(nil, forKey: "selectedCollectionURL") UserDefaults.standard.setValue(nil, forKey: "lastDraftURL") } else { // No-op } #endif WriteFreely_MultiPlatformApp.main() } } struct WriteFreely_MultiPlatformApp: App { @StateObject private var model = WriteFreelyModel.shared #if os(macOS) // swiftlint:disable:next weak_delegate @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate @State private var selectedTab = 0 #endif var body: some Scene { WindowGroup { ContentView() .onAppear(perform: { if model.editor.showAllPostsFlag { DispatchQueue.main.async { self.model.selectedCollection = nil self.model.showAllPosts = true showLastDraftOrCreateNewLocalPost() } } else { DispatchQueue.main.async { self.model.selectedCollection = model.editor.fetchSelectedCollectionFromAppStorage() self.model.showAllPosts = false showLastDraftOrCreateNewLocalPost() } } // DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { // if model.editor.lastDraftURL != nil { // self.model.selectedPost = model.editor.fetchLastDraftFromAppStorage() // } else { // createNewLocalPost() // } // } }) .environmentObject(model) - .environment(\.managedObjectContext, LocalStorageManager.standard.persistentContainer.viewContext) + .environment(\.managedObjectContext, LocalStorageManager.standard.container.viewContext) // .preferredColorScheme(preferences.selectedColorScheme) // See PreferencesModel for info. } .commands { #if os(macOS) CommandGroup(after: .appInfo, addition: { Button("Check For Updates") { SUUpdater.shared()?.checkForUpdates(self) } }) #endif CommandGroup(replacing: .newItem, addition: { Button("New Post") { createNewLocalPost() } .keyboardShortcut("n", modifiers: [.command]) }) CommandGroup(after: .newItem) { Button("Refresh Posts") { DispatchQueue.main.async { model.fetchUserCollections() model.fetchUserPosts() } } .disabled(!model.account.isLoggedIn) .keyboardShortcut("r", modifiers: [.command]) } SidebarCommands() #if os(macOS) PostCommands(model: model) #endif CommandGroup(after: .help) { Button("Visit Support Forum") { #if os(macOS) NSWorkspace().open(model.helpURL) #else UIApplication.shared.open(model.helpURL) #endif } } ToolbarCommands() TextEditingCommands() } #if os(macOS) Settings { TabView(selection: $selectedTab) { MacAccountView() .environmentObject(model) .tabItem { Image(systemName: "person.crop.circle") Text("Account") } .tag(0) MacPreferencesView(preferences: model.preferences) .tabItem { Image(systemName: "gear") Text("Preferences") } .tag(1) MacUpdatesView() .tabItem { Image(systemName: "arrow.down.circle") Text("Updates") } .tag(2) } .frame(minWidth: 500, maxWidth: 500, minHeight: 200) .padding() // .preferredColorScheme(preferences.selectedColorScheme) // See PreferencesModel for info. } #endif } private func showLastDraftOrCreateNewLocalPost() { if model.editor.lastDraftURL != nil { self.model.selectedPost = model.editor.fetchLastDraftFromAppStorage() } else { createNewLocalPost() } } private func createNewLocalPost() { withAnimation { // Un-set the currently selected post self.model.selectedPost = nil } // Create the new-post managed object let managedPost = model.editor.generateNewLocalPost(withFont: model.preferences.font) withAnimation { // Set it as the selectedPost DispatchQueue.main.asyncAfter(deadline: .now()) { self.model.selectedPost = managedPost } } } } diff --git a/iOS/PostEditor/PostEditorView.swift b/iOS/PostEditor/PostEditorView.swift index a2a0194..7a78ad6 100644 --- a/iOS/PostEditor/PostEditorView.swift +++ b/iOS/PostEditor/PostEditorView.swift @@ -1,268 +1,268 @@ import SwiftUI struct PostEditorView: View { @EnvironmentObject var model: WriteFreelyModel @Environment(\.horizontalSizeClass) var horizontalSizeClass @Environment(\.managedObjectContext) var moc @Environment(\.presentationMode) var presentationMode @ObservedObject var post: WFAPost @State private var updatingTitleFromServer: Bool = false @State private var updatingBodyFromServer: Bool = false @State private var selectedCollection: WFACollection? @FetchRequest( entity: WFACollection.entity(), sortDescriptors: [NSSortDescriptor(keyPath: \WFACollection.title, ascending: true)] ) var collections: FetchedResults var body: some View { VStack { if post.hasNewerRemoteCopy { RemoteChangePromptView( remoteChangeType: .remoteCopyUpdated, buttonHandler: { model.updateFromServer(post: post) } ) } else if post.wasDeletedFromServer { RemoteChangePromptView( remoteChangeType: .remoteCopyDeleted, buttonHandler: { self.presentationMode.wrappedValue.dismiss() DispatchQueue.main.async { model.posts.remove(post) } } ) } PostTextEditingView( post: _post, updatingTitleFromServer: $updatingTitleFromServer, updatingBodyFromServer: $updatingBodyFromServer ) } .navigationBarTitleDisplayMode(.inline) .padding() .toolbar { ToolbarItem(placement: .principal) { PostEditorStatusToolbarView(post: post) } ToolbarItem(placement: .primaryAction) { if model.isProcessingRequest { ProgressView() } else { Menu(content: { if post.status == PostStatus.local.rawValue { Menu(content: { Label("Publish to…", systemImage: "paperplane") Button(action: { if model.account.isLoggedIn { post.collectionAlias = nil publishPost() } else { self.model.isPresentingSettingsView = true } }, label: { Text(" \(model.account.server == "https://write.as" ? "Anonymous" : "Drafts")") }) ForEach(collections) { collection in Button(action: { if model.account.isLoggedIn { post.collectionAlias = collection.alias publishPost() } else { self.model.isPresentingSettingsView = true } }, label: { Text(" \(collection.title)") }) } }, label: { Label("Publish…", systemImage: "paperplane") }) .accessibilityHint(Text("Choose the blog you want to publish this post to")) .disabled(post.body.count == 0) } else { Button(action: { if model.account.isLoggedIn { publishPost() } else { self.model.isPresentingSettingsView = true } }, label: { Label("Publish", systemImage: "paperplane") }) .disabled(post.status == PostStatus.published.rawValue || post.body.count == 0) } Button(action: { sharePost() }, label: { Label("Share", systemImage: "square.and.arrow.up") }) .accessibilityHint(Text("Open the system share sheet to share a link to this post")) .disabled(post.postId == nil) // Button(action: { // print("Tapped 'Delete...' button") // }, label: { // Label("Delete…", systemImage: "trash") // }) if model.account.isLoggedIn && post.status != PostStatus.local.rawValue { Section(header: Text("Move To Collection")) { Label("Move to:", systemImage: "arrowshape.zigzag.right") Picker(selection: $selectedCollection, label: Text("Move to…")) { Text( " \(model.account.server == "https://write.as" ? "Anonymous" : "Drafts")" ).tag(nil as WFACollection?) ForEach(collections) { collection in Text(" \(collection.title)").tag(collection as WFACollection?) } } } } }, label: { ZStack { Image("does.not.exist") .accessibilityHidden(true) Image(systemName: "ellipsis.circle") .imageScale(.large) .accessibilityHidden(true) } }) .accessibilityLabel(Text("Menu")) .accessibilityHint(Text("Opens a context menu to publish, share, or move the post")) .onTapGesture { hideKeyboard() } .disabled(post.body.count == 0) } } } .onChange(of: post.hasNewerRemoteCopy, perform: { _ in if !post.hasNewerRemoteCopy { updatingTitleFromServer = true updatingBodyFromServer = true } }) .onChange(of: selectedCollection, perform: { [selectedCollection] newCollection in if post.collectionAlias == newCollection?.alias { return } else { post.collectionAlias = newCollection?.alias model.move(post: post, from: selectedCollection, to: newCollection) } }) .onChange(of: post.status, perform: { value in if value != PostStatus.published.rawValue { self.model.editor.saveLastDraft(post) } else { self.model.editor.clearLastDraft() } DispatchQueue.main.async { LocalStorageManager.standard.saveContext() } }) .onAppear(perform: { self.selectedCollection = collections.first { $0.alias == post.collectionAlias } if post.status != PostStatus.published.rawValue { DispatchQueue.main.async { self.model.editor.saveLastDraft(post) } } else { self.model.editor.clearLastDraft() } }) .onDisappear(perform: { self.model.editor.clearLastDraft() if post.title.count == 0 && post.body.count == 0 && post.status == PostStatus.local.rawValue && post.updatedDate == nil && post.postId == nil { DispatchQueue.main.async { model.posts.remove(post) } } else if post.status != PostStatus.published.rawValue { DispatchQueue.main.async { LocalStorageManager.standard.saveContext() } } }) } private func publishPost() { DispatchQueue.main.async { LocalStorageManager.standard.saveContext() model.publish(post: post) } #if os(iOS) self.hideKeyboard() #endif } private func sharePost() { // If the post doesn't have a post ID, it isn't published, and therefore can't be shared, so return early. guard let postId = post.postId else { return } var urlString: String if let postSlug = post.slug, let postCollectionAlias = post.collectionAlias { // This post is in a collection, so share the URL as baseURL/postSlug. let urls = collections.filter { $0.alias == postCollectionAlias } let baseURL = urls.first?.url ?? "\(model.account.server)/\(postCollectionAlias)/" urlString = "\(baseURL)\(postSlug)" } else { // This is a draft post, so share the URL as server/postID urlString = "\(model.account.server)/\(postId)" } guard let data = URL(string: urlString) else { return } let activityView = UIActivityViewController(activityItems: [data], applicationActivities: nil) UIApplication.shared.windows.first?.rootViewController?.present(activityView, animated: true, completion: nil) if UIDevice.current.userInterfaceIdiom == .pad { activityView.popoverPresentationController?.permittedArrowDirections = .up activityView.popoverPresentationController?.sourceView = UIApplication.shared.windows.first activityView.popoverPresentationController?.sourceRect = CGRect( x: UIScreen.main.bounds.width, y: -125, width: 200, height: 200 ) } } } struct PostEditorView_EmptyPostPreviews: PreviewProvider { static var previews: some View { - let context = LocalStorageManager.standard.persistentContainer.viewContext + let context = LocalStorageManager.standard.container.viewContext let testPost = WFAPost(context: context) testPost.createdDate = Date() testPost.appearance = "norm" let model = WriteFreelyModel() return PostEditorView(post: testPost) .environment(\.managedObjectContext, context) .environmentObject(model) } } struct PostEditorView_ExistingPostPreviews: PreviewProvider { static var previews: some View { - let context = LocalStorageManager.standard.persistentContainer.viewContext + let context = LocalStorageManager.standard.container.viewContext let testPost = WFAPost(context: context) testPost.title = "Test Post Title" testPost.body = "Here's some cool sample body text." testPost.createdDate = Date() testPost.appearance = "code" testPost.hasNewerRemoteCopy = true let model = WriteFreelyModel() return PostEditorView(post: testPost) .environment(\.managedObjectContext, context) .environmentObject(model) } }