Skip to content

Offline Mode: Sync Publishing #22689

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 10 commits into from
Mar 5, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions WordPress/Classes/Models/BasePost.h
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ NS_ASSUME_NONNULL_BEGIN

/**
BOOL flag set to true if the post is first time published.

- note: Deprecated (kahu-offline-mode)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I suggest using this marker for anything that's scheduled for removal once the project is live. I'm not "officially"deprecating these APIs to avoid polluting the project with warnings.

*/
@property (nonatomic, assign) BOOL isFirstTimePublish;

Expand Down
111 changes: 111 additions & 0 deletions WordPress/Classes/Services/PostCoordinator+Notices.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import UIKit

extension PostCoordinator {
static func makePublishSuccessNotice(for post: AbstractPost) -> Notice {
var message: String {
let title = post.titleForDisplay() ?? ""
if !title.isEmpty {
return title
}
return post.blog.displayURL as String? ?? ""
}
return Notice(title: Strings.publishSuccessTitle(for: post),
message: message,
feedbackType: .success,
notificationInfo: makePublishSuccessNotificationInfo(for: post),
actionTitle: Strings.view,
actionHandler: { _ in
PostNoticeNavigationCoordinator.presentPostEpilogue(for: post)
})
}

private static func makePublishSuccessNotificationInfo(for post: AbstractPost) -> NoticeNotificationInfo {
var title: String {
let title = post.titleForDisplay() ?? ""
guard !title.isEmpty else {
return Strings.publishSuccessTitle(for: post)
}
return "“\(title)” \(Strings.publishSuccessTitle(for: post))"
}
var body: String {
post.blog.displayURL as String? ?? ""
}
return NoticeNotificationInfo(
identifier: UUID().uuidString,
categoryIdentifier: InteractiveNotificationsManager.NoteCategoryDefinition.postUploadSuccess.rawValue,
title: title,
body: body,
userInfo: [
PostNoticeUserInfoKey.postID: post.objectID.uriRepresentation().absoluteString
])
}

static func makePublishFailureNotice(for post: AbstractPost, error: Error) -> Notice {
return Notice(
title: Strings.uploadFailed,
message: error.localizedDescription,
feedbackType: .error,
notificationInfo: makePublishFailureNotificationInfo(for: post, error: error)
)
}

private static func makePublishFailureNotificationInfo(for post: AbstractPost, error: Error) -> NoticeNotificationInfo {
var title: String {
let title = post.titleForDisplay() ?? ""
guard !title.isEmpty else {
return Strings.uploadFailed
}
return "“\(title)” \(Strings.uploadFailed)"
}
return NoticeNotificationInfo(
identifier: UUID().uuidString,
categoryIdentifier: nil,
title: title,
body: error.localizedDescription
)
}
}

private enum Strings {
static let view = NSLocalizedString("postNotice.view", value: "View", comment: "Button title. Displays a summary / sharing screen for a specific post.")

static let uploadFailed = NSLocalizedString("postNotice.uploadFailed", value: "Upload failed", comment: "A post upload failed notification.")

static func publishSuccessTitle(for post: AbstractPost, isFirstTimePublish: Bool = true) -> String {
switch post {
case let post as Post:
switch post.status {
case .draft:
return NSLocalizedString("postNotice.postDraftCreated", value: "Post draft uploaded", comment: "Title of notification displayed when a post has been successfully saved as a draft.")
case .scheduled:
return NSLocalizedString("postNotice.postScheduled", value: "Post scheduled", comment: "Title of notification displayed when a post has been successfully scheduled.")
case .pending:
return NSLocalizedString("postNotice.postPendingReview", value: "Post pending review", comment: "Title of notification displayed when a post has been successfully saved as a draft.")
default:
if isFirstTimePublish {
return NSLocalizedString("postNotice.postPublished", value: "Post published", comment: "Title of notification displayed when a post has been successfully published.")
} else {
return NSLocalizedString("postNotice.postUpdated", value: "Post updated", comment: "Title of notification displayed when a post has been successfully updated.")
}
}
case let page as Page:
switch page.status {
case .draft:
return NSLocalizedString("postNotice.pageDraftCreated", value: "Page draft uploaded", comment: "Title of notification displayed when a page has been successfully saved as a draft.")
case .scheduled:
return NSLocalizedString("postNotice.pageScheduled", value: "Page scheduled", comment: "Title of notification displayed when a page has been successfully scheduled.")
case .pending:
return NSLocalizedString("postNotice.pagePending", value: "Page pending review", comment: "Title of notification displayed when a page has been successfully saved as a draft.")
default:
if isFirstTimePublish {
return NSLocalizedString("postNotice.pagePublished", value: "Page published", comment: "Title of notification displayed when a page has been successfully published.")
} else {
return NSLocalizedString("postNotice.pageUpdated", value: "Page updated", comment: "Title of notification displayed when a page has been successfully updated.")
}
}
default:
assertionFailure("Unexpected post type: \(post)")
return ""
}
}
}
51 changes: 49 additions & 2 deletions WordPress/Classes/Services/PostCoordinator.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import Aztec
import Foundation
import WordPressKit
import WordPressFlux

protocol PostCoordinatorDelegate: AnyObject {
Expand All @@ -15,7 +16,7 @@ class PostCoordinator: NSObject {

@objc static let shared = PostCoordinator()

private let coreDataStack: CoreDataStack
private let coreDataStack: CoreDataStackSwift

private var mainContext: NSManagedObjectContext {
coreDataStack.mainContext
Expand All @@ -41,7 +42,7 @@ class PostCoordinator: NSObject {
mediaCoordinator: MediaCoordinator? = nil,
failedPostsFetcher: FailedPostsFetcher? = nil,
actionDispatcherFacade: ActionDispatcherFacade = ActionDispatcherFacade(),
coreDataStack: CoreDataStack = ContextManager.sharedInstance()) {
coreDataStack: CoreDataStackSwift = ContextManager.sharedInstance()) {
self.coreDataStack = coreDataStack

let mainContext = self.coreDataStack.mainContext
Expand Down Expand Up @@ -94,6 +95,7 @@ class PostCoordinator: NSObject {
}
}

/// - note: Deprecated (kahu-offline-mode) (See PostCoordinator.publish)
func publish(_ post: AbstractPost) {
if post.status == .draft {
post.status = .publish
Expand All @@ -109,6 +111,50 @@ class PostCoordinator: NSObject {
save(post)
}

/// Publishes the post according to the current settings and user capabilities.
///
/// - warning: Before publishing, ensure that the media for the post got
/// uploaded. Managing media is not the responsibility of `PostRepository.`
///
/// - warning: Work-in-progress (kahu-offline-mode)
@MainActor
func _publish(_ post: AbstractPost) async throws {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm underscoring the methods that are work-in-progress and are only designed to be used in the scope of the project. It also helps avoid the name conflicts.

let parameters = PostHelper.remotePost(with: post)
if post.status == .draft {
parameters.status = PostStatusPublish
parameters.date = Date()
} else {
// Publish according to the currrent post settings: private, scheduled, etc.
}
do {
let repository = PostRepository(coreDataStack: coreDataStack)
let post = try await repository._upload(parameters, for: post)
didPublish(post)
show(PostCoordinator.makePublishSuccessNotice(for: post))
} catch {
show(PostCoordinator.makePublishFailureNotice(for: post, error: error))
throw error
}
}

@MainActor
private func didPublish(_ post: AbstractPost) {
if post.status == .publish {
QuickStartTourGuide.shared.complete(tour: QuickStartPublishTour(), silentlyForBlog: post.blog)
}
if post.status == .scheduled {
notifyNewPostScheduled()
} else if post.status == .publish {
notifyNewPostPublished()
}
SearchManager.shared.indexItem(post)
AppRatingUtility.shared.incrementSignificantEvent()
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Everything non-editor related is now handled by PostCoordinator to allow you to safely publish outside of the editor.

Noting that it no longer sets post.shouldAttemptAutoUpload = true and no longer uses isFirstTimePublish which should never have been part of the model layer.

}

private func show(_ notice: Notice) {
actionDispatcherFacade.dispatch(NoticeAction.post(notice))
}

func moveToDraft(_ post: AbstractPost) {
post.status = .draft
save(post)
Expand Down Expand Up @@ -233,6 +279,7 @@ class PostCoordinator: NSObject {
Post.refreshStatus(with: coreDataStack)
}

/// - note: Deprecated (kahu-offline-mode)
private func upload(post: AbstractPost, forceDraftIfCreating: Bool, completion: ((Result<AbstractPost, Error>) -> ())? = nil) {
mainService.uploadPost(post, forceDraftIfCreating: forceDraftIfCreating, success: { [weak self] uploadedPost in
guard let uploadedPost = uploadedPost else {
Expand Down
91 changes: 82 additions & 9 deletions WordPress/Classes/Services/PostRepository.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import Foundation
import WordPressKit

final class PostRepository {

Expand All @@ -10,7 +11,8 @@ final class PostRepository {
private let coreDataStack: CoreDataStackSwift
private let remoteFactory: PostServiceRemoteFactory

init(coreDataStack: CoreDataStackSwift, remoteFactory: PostServiceRemoteFactory = PostServiceRemoteFactory()) {
init(coreDataStack: CoreDataStackSwift = ContextManager.shared,
remoteFactory: PostServiceRemoteFactory = PostServiceRemoteFactory()) {
self.coreDataStack = coreDataStack
self.remoteFactory = remoteFactory
}
Expand All @@ -22,14 +24,7 @@ final class PostRepository {
/// - blogID: The blog that has the post.
/// - Returns: The stored post object id.
func getPost(withID postID: NSNumber, from blogID: TaggedManagedObjectID<Blog>) async throws -> TaggedManagedObjectID<AbstractPost> {
let remote = try await coreDataStack.performQuery { [remoteFactory] context in
let blog = try context.existingObject(with: blogID)
return remoteFactory.forBlog(blog)
}

guard let remote else {
throw PostRepository.Error.remoteAPIUnavailable
}
let remote = try await getRemoteService(forblogID: blogID)

let remotePost: RemotePost? = try await withCheckedThrowingContinuation { continuation in
remote.getPostWithID(
Expand Down Expand Up @@ -63,6 +58,65 @@ final class PostRepository {
}
}

/// Uploads the changes to the given post to the server and deletes the
/// uploaded local revision afterward. If the post doesn't have an associated
/// remote ID, creates a new post.
///
/// - note: This method is a low-level primitive for syncing the latest
/// post date to the server.
///
/// - warning: Before publishing, ensure that the media for the post got
/// uploaded. Managing media is not the responsibility of `PostRepository.`
///
/// - warning: Work-in-progress (kahu-offline-mode)
///
/// - parameter post: The revision of the post.
@MainActor
func _upload(_ post: AbstractPost) async throws {
let remotePost = PostHelper.remotePost(with: post)
try await _upload(remotePost, for: post)
}

/// Uploads the changes to the given post to the server and deletes the
/// uploaded local revision afterward. If the post doesn't have an associated
/// remote ID, creates a new post.
///
/// - note: This method is a low-level primitive for syncing the latest
/// post date to the server.
///
/// - warning: Before publishing, ensure that the media for the post got
/// uploaded. Managing media is not the responsibility of `PostRepository.`
///
/// - warning: Work-in-progress (kahu-offline-mode)
///
/// - parameter post: The revision of the post.
@discardableResult @MainActor
func _upload(_ parameters: RemotePost, for post: AbstractPost) async throws -> AbstractPost {
let service = try getRemoteService(for: post.blog)
let uploadedPost: RemotePost
if let postID = post.postID, postID.intValue > 0 {
uploadedPost = try await service.update(parameters)
} else {
uploadedPost = try await service.create(parameters)
}

let post = deleteRevision(for: post)
let context = coreDataStack.mainContext
PostHelper.update(post, with: uploadedPost, in: context)
PostService(managedObjectContext: context)
.updateMediaFor(post: post, success: {}, failure: { _ in })
ContextManager.shared.save(context)
return post
}

private func deleteRevision(for post: AbstractPost) -> AbstractPost {
if post.isRevision(), let original = post.original {
original.deleteRevision()
return original
}
return post
}

/// Permanently delete the given post from local database and the post's WordPress site.
///
/// - Parameter postID: Object ID of the post
Expand Down Expand Up @@ -229,7 +283,26 @@ final class PostRepository {
PostHelper.update(post, with: updatedRemotePost, in: context)
}
}
}

private extension PostRepository {
func getRemoteService(forblogID blogID: TaggedManagedObjectID<Blog>) async throws -> PostServiceRemote {
let remote = try await coreDataStack.performQuery { [remoteFactory] context in
let blog = try context.existingObject(with: blogID)
return remoteFactory.forBlog(blog)
}
guard let remote else {
throw PostRepository.Error.remoteAPIUnavailable
}
return remote
}

func getRemoteService(for blog: Blog) throws -> PostServiceRemote {
guard let remote = remoteFactory.forBlog(blog) else {
throw PostRepository.Error.remoteAPIUnavailable
}
return remote
}
}

// MARK: - Posts/Pages List
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import Foundation

extension PostService {
/// - note: Deprecated (kahu-offline-mode)
@objc func updateMediaFor(post: AbstractPost,
success: @escaping () -> Void,
failure: @escaping (Error?) -> Void) {
Expand Down
4 changes: 4 additions & 0 deletions WordPress/Classes/Services/PostService.h
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,8 @@ extern const NSUInteger PostServiceDefaultNumberToSync;
parameter, since if the input post was a revision, it will no longer exist once the upload
succeeds.
@param failure A failure block

- note: Deprecated (kahu-offline-mode) (see PostRepository.upload)
*/
- (void)uploadPost:(AbstractPost *)post
success:(nullable void (^)(AbstractPost *post))success
Expand All @@ -105,6 +107,8 @@ extern const NSUInteger PostServiceDefaultNumberToSync;

Another use case of `forceDraftIfCreating` is to create the post in the background so we can
periodically auto-save it. Again, we'd still want to create it as a `.draft` status.

- note: Deprecated (kahu-offline-mode) (see PostRepository.upload)
*/
- (void)uploadPost:(AbstractPost *)post
forceDraftIfCreating:(BOOL)forceDraftIfCreating
Expand Down
26 changes: 26 additions & 0 deletions WordPress/Classes/Services/PostServiceRemote+Concurrency.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import Foundation
import WordPressKit

extension PostServiceRemote {
func update(_ post: RemotePost) async throws -> RemotePost {
try await withUnsafeThrowingContinuation { continuation in
update(post, success: {
assert($0 != nil)
continuation.resume(returning: $0 ?? post)
}, failure: { error in
continuation.resume(throwing: error ?? URLError(.unknown))
})
}
}

func create(_ post: RemotePost) async throws -> RemotePost {
try await withUnsafeThrowingContinuation { continuation in
createPost(post, success: {
assert($0 != nil)
continuation.resume(returning: $0 ?? post)
}, failure: { error in
continuation.resume(throwing: error ?? URLError(.unknown))
})
}
}
}
Loading