From 209e4d1f6847a2b7608e5880fcb608e79febd629 Mon Sep 17 00:00:00 2001 From: Tony Li Date: Tue, 7 Jul 2026 09:14:05 +1200 Subject: [PATCH 1/5] Format PostCoordinator with swift-format --- .../Classes/Services/PostCoordinator.swift | 245 ++++++++++++------ 1 file changed, 172 insertions(+), 73 deletions(-) diff --git a/WordPress/Classes/Services/PostCoordinator.swift b/WordPress/Classes/Services/PostCoordinator.swift index b464bec59047..b643ff389d4d 100644 --- a/WordPress/Classes/Services/PostCoordinator.swift +++ b/WordPress/Classes/Services/PostCoordinator.swift @@ -61,16 +61,23 @@ class PostCoordinator: NSObject { // MARK: - Initializers - init(mediaCoordinator: MediaCoordinator? = nil, - actionDispatcherFacade: ActionDispatcherFacade = ActionDispatcherFacade(), - coreDataStack: CoreDataStackSwift = ContextManager.shared) { + init( + mediaCoordinator: MediaCoordinator? = nil, + actionDispatcherFacade: ActionDispatcherFacade = ActionDispatcherFacade(), + coreDataStack: CoreDataStackSwift = ContextManager.shared + ) { self.coreDataStack = coreDataStack self.mediaCoordinator = mediaCoordinator ?? MediaCoordinator.shared self.actionDispatcherFacade = actionDispatcherFacade super.init() - NotificationCenter.default.addObserver(self, selector: #selector(didUpdateReachability), name: .reachabilityUpdated, object: nil) + NotificationCenter.default.addObserver( + self, + selector: #selector(didUpdateReachability), + name: .reachabilityUpdated, + object: nil + ) } /// Publishes the post according to the current settings and user capabilities. @@ -115,10 +122,9 @@ class PostCoordinator: NSObject { // If the post was previously scheduled and the user wants to publish // it without specifying a new publish date, we have to send `.now` // to ensure it gets published immediatelly. - if (changes.status == Post.Status.publish.rawValue || - changes.status == Post.Status.publishPrivate.rawValue) && - previousStatus == .scheduled && - changes.date == nil { + if (changes.status == Post.Status.publish.rawValue || changes.status == Post.Status.publishPrivate.rawValue) + && previousStatus == .scheduled && changes.date == nil + { changes.date = .now } @@ -165,7 +171,11 @@ class PostCoordinator: NSObject { wpAssertionFailure("Failed to show an error alert") return } - let alert = UIAlertController(title: SharedStrings.Error.generic, message: error.localizedDescription, preferredStyle: .alert) + let alert = UIAlertController( + title: SharedStrings.Error.generic, + message: error.localizedDescription, + preferredStyle: .alert + ) if let error = error as? PostRepository.PostSaveError { switch error { case .conflict(let latest): @@ -205,7 +215,12 @@ class PostCoordinator: NSObject { return } let repository = PostRepository(coreDataStack: coreDataStack) - let controller = ResolveConflictViewController(post: post, remoteRevision: remoteRevision, repository: repository, source: source) + let controller = ResolveConflictViewController( + post: post, + remoteRevision: remoteRevision, + repository: repository, + source: source + ) let navigation = UINavigationController(rootViewController: controller) topViewController.present(navigation, animated: true) } @@ -297,7 +312,10 @@ class PostCoordinator: NSObject { /// - note: It should typically only be called once during the app launch. func initializeSync() { let request = NSFetchRequest(entityName: NSStringFromClass(AbstractPost.self)) - request.predicate = NSPredicate(format: "remoteStatusNumber == %i", AbstractPostRemoteStatus.syncNeeded.rawValue) + request.predicate = NSPredicate( + format: "remoteStatusNumber == %i", + AbstractPostRemoteStatus.syncNeeded.rawValue + ) do { let revisions = try coreDataStack.mainContext.fetch(request) let originals = Set(revisions.map { $0.getOriginal() }) @@ -329,12 +347,14 @@ class PostCoordinator: NSObject { if operation.isCancelled { return // Cancelled immediatelly } - _ = await syncEvents.first(where: { [expected = operation] event in - if case .finished(let operation, _) = event, operation === expected { - return true - } - return false - }).values.first(where: { _ in true }) + _ = + await syncEvents.first(where: { [expected = operation] event in + if case .finished(let operation, _) = event, operation === expected { + return true + } + return false + }) + .values.first(where: { _ in true }) } /// Resumes sync for the given post. @@ -411,7 +431,9 @@ class PostCoordinator: NSObject { } func log(_ string: String) { - DDLogInfo("sync-operation(\(id)) (\(post.objectID.shortDescription)→\(revision.objectID.shortDescription))) \(string)") + DDLogInfo( + "sync-operation(\(id)) (\(post.objectID.shortDescription)→\(revision.objectID.shortDescription))) \(string)" + ) } } @@ -443,7 +465,8 @@ class PostCoordinator: NSObject { let worker = getWorker(for: post) if let date = revision.confirmedChangesTimestamp, - Date.now.timeIntervalSince(date) > SyncWorker.maximumRetryTimeInterval { + Date.now.timeIntervalSince(date) > SyncWorker.maximumRetryTimeInterval + { worker.error = PostCoordinator.SavingError.maximumRetryTimeIntervalReached postDidUpdateNotification(for: post) return worker.log("stopping – failing to upload changes for too long") @@ -540,7 +563,8 @@ class PostCoordinator: NSObject { } let delay = worker.nextRetryDelay - worker.retryTimer = Timer.scheduledTimer(withTimeInterval: delay, repeats: false) { [weak self, weak worker] _ in + worker.retryTimer = Timer.scheduledTimer(withTimeInterval: delay, repeats: false) { + [weak self, weak worker] _ in guard let self, let worker else { return } self.didRetryTimerFire(for: worker) } @@ -561,13 +585,16 @@ class PostCoordinator: NSObject { @objc private func didUpdateReachability(_ notification: Foundation.Notification) { guard let reachable = notification.userInfo?[Foundation.Notification.reachabilityKey], - (reachable as? Bool) == true else { + (reachable as? Bool) == true + else { return } for worker in workers.values { if let error = worker.error, - let urlError = (error as NSError).underlyingErrors.first as? URLError, - urlError.code == .notConnectedToInternet || urlError.code == .networkConnectionLost || urlError.code == .timedOut { + let urlError = (error as NSError).underlyingErrors.first as? URLError, + urlError.code == .notConnectedToInternet || urlError.code == .networkConnectionLost + || urlError.code == .timedOut + { worker.log("connection is reachable – retrying now") startSync(for: worker.post) } @@ -580,7 +607,11 @@ class PostCoordinator: NSObject { } private func postDidUpdateNotification(for post: AbstractPost) { - NotificationCenter.default.post(name: .postCoordinatorDidUpdate, object: self, userInfo: [NSUpdatedObjectsKey: Set([post])]) + NotificationCenter.default.post( + name: .postCoordinatorDidUpdate, + object: self, + userInfo: [NSUpdatedObjectsKey: Set([post])] + ) } // MARK: - Upload Resources @@ -599,8 +630,11 @@ class PostCoordinator: NSObject { /// - Parameter automatedRetry: if this is an automated retry, without user intervenction /// - Parameter then: a block to perform after post is ready to be saved /// - private func prepareToSave(_ post: AbstractPost, automatedRetry: Bool = false, - then completion: @escaping (Result) -> ()) { + private func prepareToSave( + _ post: AbstractPost, + automatedRetry: Bool = false, + then completion: @escaping (Result) -> () + ) { post.autoUploadAttemptsCount = NSNumber(value: automatedRetry ? post.autoUploadAttemptsCount.intValue + 1 : 0) guard mediaCoordinator.uploadMedia(for: post, automatedRetry: automatedRetry) else { @@ -642,10 +676,16 @@ class PostCoordinator: NSObject { } func isUploading(post: AbstractPost) -> Bool { - return post.remoteStatus == .pushing + post.remoteStatus == .pushing } - func posts(for blog: Blog, containsTitle title: String, excludingPostIDs excludedPostIDs: [Int] = [], entityName: String? = nil, publishedOnly: Bool = false) -> NSFetchedResultsController { + func posts( + for blog: Blog, + containsTitle title: String, + excludingPostIDs excludedPostIDs: [Int] = [], + entityName: String? = nil, + publishedOnly: Bool = false + ) -> NSFetchedResultsController { let context = self.mainContext let fetchRequest = NSFetchRequest(entityName: entityName ?? AbstractPost.entityName()) @@ -668,7 +708,12 @@ class PostCoordinator: NSObject { fetchRequest.predicate = resultPredicate - let controller = NSFetchedResultsController(fetchRequest: fetchRequest, managedObjectContext: context, sectionNameKeyPath: nil, cacheName: nil) + let controller = NSFetchedResultsController( + fetchRequest: fetchRequest, + managedObjectContext: context, + sectionNameKeyPath: nil, + cacheName: nil + ) do { try controller.performFetch() } catch { @@ -701,11 +746,15 @@ class PostCoordinator: NSObject { return post.titleForDisplay() } - private func observeMedia(for post: AbstractPost, completion: @escaping (Result) -> ()) -> UUID { + private func observeMedia( + for post: AbstractPost, + completion: @escaping (Result) -> () + ) -> UUID { // Only observe if we're not already let handleSingleMediaFailure = { [weak self] (error: Error) -> Void in guard let `self` = self, - self.isObserving(post: post) else { + self.isObserving(post: post) + else { return } @@ -718,45 +767,49 @@ class PostCoordinator: NSObject { completion(.failure(SavingError.mediaFailure(post, error))) } - return mediaCoordinator.addObserver({ [weak self] media, state in - guard let `self` = self else { - return - } - switch state { - case .ended: - let successHandler = { - self.updateMediaBlocksBeforeSave(in: post, with: [media]) - if post.media.allSatisfy({ $0.remoteStatus == .sync }) { - self.removeObserver(for: post) - completion(.success(post)) - } + return mediaCoordinator.addObserver( + { [weak self] media, state in + guard let `self` = self else { + return } - switch media.mediaType { - case .video: - EditorMediaUtility.fetchRemoteVideoURL(for: media, in: post) { result in - switch result { - case .failure(let error): - handleSingleMediaFailure(error) - case .success(let videoURL): - media.remoteURL = videoURL.absoluteString - successHandler() + switch state { + case .ended: + let successHandler = { + self.updateMediaBlocksBeforeSave(in: post, with: [media]) + if post.media.allSatisfy({ $0.remoteStatus == .sync }) { + self.removeObserver(for: post) + completion(.success(post)) + } + } + switch media.mediaType { + case .video: + EditorMediaUtility.fetchRemoteVideoURL(for: media, in: post) { result in + switch result { + case .failure(let error): + handleSingleMediaFailure(error) + case .success(let videoURL): + media.remoteURL = videoURL.absoluteString + successHandler() + } } + default: + successHandler() } + case .failed(let error): + handleSingleMediaFailure(error) default: - successHandler() + DDLogInfo("Post Coordinator -> Media state: \(state)") } - case .failed(let error): - handleSingleMediaFailure(error) - default: - DDLogInfo("Post Coordinator -> Media state: \(state)") - } - }, forMediaFor: post) + }, + forMediaFor: post + ) } private func updateReferences(to media: Media, in contentBlocks: [GutenbergParsedBlock], post: AbstractPost) { guard var postContent = post.content, let mediaID = media.mediaID?.intValue, - let remoteURLStr = media.remoteURL else { + let remoteURLStr = media.remoteURL + else { return } var imageURL = remoteURLStr @@ -779,53 +832,99 @@ class PostCoordinator: NSObject { var aztecProcessors: [Processor] = [] // File block can upload any kind of media. - let gutenbergFileProcessor = GutenbergFileUploadProcessor(mediaUploadID: gutenbergMediaUploadID, serverMediaID: mediaID, remoteURLString: remoteURLStr) + let gutenbergFileProcessor = GutenbergFileUploadProcessor( + mediaUploadID: gutenbergMediaUploadID, + serverMediaID: mediaID, + remoteURLString: remoteURLStr + ) gutenbergBlockProcessors.append(gutenbergFileProcessor) if media.mediaType == .image { - let gutenbergImgPostUploadProcessor = GutenbergImgUploadProcessor(mediaUploadID: gutenbergMediaUploadID, serverMediaID: mediaID, remoteURLString: imageURL) + let gutenbergImgPostUploadProcessor = GutenbergImgUploadProcessor( + mediaUploadID: gutenbergMediaUploadID, + serverMediaID: mediaID, + remoteURLString: imageURL + ) gutenbergBlockProcessors.append(gutenbergImgPostUploadProcessor) - let gutenbergGalleryPostUploadProcessor = GutenbergGalleryUploadProcessor(mediaUploadID: gutenbergMediaUploadID, serverMediaID: mediaID, remoteURLString: imageURL, mediaLink: mediaLink) + let gutenbergGalleryPostUploadProcessor = GutenbergGalleryUploadProcessor( + mediaUploadID: gutenbergMediaUploadID, + serverMediaID: mediaID, + remoteURLString: imageURL, + mediaLink: mediaLink + ) gutenbergBlockProcessors.append(gutenbergGalleryPostUploadProcessor) - let imgPostUploadProcessor = ImgUploadProcessor(mediaUploadID: mediaUploadID, remoteURLString: remoteURLStr, width: media.width?.intValue, height: media.height?.intValue) + let imgPostUploadProcessor = ImgUploadProcessor( + mediaUploadID: mediaUploadID, + remoteURLString: remoteURLStr, + width: media.width?.intValue, + height: media.height?.intValue + ) aztecProcessors.append(imgPostUploadProcessor) - let gutenbergCoverPostUploadProcessor = GutenbergCoverUploadProcessor(mediaUploadID: gutenbergMediaUploadID, serverMediaID: mediaID, remoteURLString: remoteURLStr) + let gutenbergCoverPostUploadProcessor = GutenbergCoverUploadProcessor( + mediaUploadID: gutenbergMediaUploadID, + serverMediaID: mediaID, + remoteURLString: remoteURLStr + ) gutenbergProcessors.append(gutenbergCoverPostUploadProcessor) } else if media.mediaType == .video { - let gutenbergVideoPostUploadProcessor = GutenbergVideoUploadProcessor(mediaUploadID: gutenbergMediaUploadID, serverMediaID: mediaID, remoteURLString: remoteURLStr) + let gutenbergVideoPostUploadProcessor = GutenbergVideoUploadProcessor( + mediaUploadID: gutenbergMediaUploadID, + serverMediaID: mediaID, + remoteURLString: remoteURLStr + ) gutenbergProcessors.append(gutenbergVideoPostUploadProcessor) - let gutenbergCoverPostUploadProcessor = GutenbergCoverUploadProcessor(mediaUploadID: gutenbergMediaUploadID, serverMediaID: mediaID, remoteURLString: remoteURLStr) + let gutenbergCoverPostUploadProcessor = GutenbergCoverUploadProcessor( + mediaUploadID: gutenbergMediaUploadID, + serverMediaID: mediaID, + remoteURLString: remoteURLStr + ) gutenbergProcessors.append(gutenbergCoverPostUploadProcessor) - let videoPostUploadProcessor = VideoUploadProcessor(mediaUploadID: mediaUploadID, remoteURLString: remoteURLStr, videoPressID: media.videopressGUID) + let videoPostUploadProcessor = VideoUploadProcessor( + mediaUploadID: mediaUploadID, + remoteURLString: remoteURLStr, + videoPressID: media.videopressGUID + ) aztecProcessors.append(videoPostUploadProcessor) if let videoPressGUID = media.videopressGUID { - let gutenbergVideoPressUploadProcessor = GutenbergVideoPressUploadProcessor(mediaUploadID: gutenbergMediaUploadID, serverMediaID: mediaID, videoPressGUID: videoPressGUID) + let gutenbergVideoPressUploadProcessor = GutenbergVideoPressUploadProcessor( + mediaUploadID: gutenbergMediaUploadID, + serverMediaID: mediaID, + videoPressGUID: videoPressGUID + ) gutenbergProcessors.append(gutenbergVideoPressUploadProcessor) } } else if media.mediaType == .audio { - let gutenbergAudioProcessor = GutenbergAudioUploadProcessor(mediaUploadID: gutenbergMediaUploadID, serverMediaID: mediaID, remoteURLString: remoteURLStr) + let gutenbergAudioProcessor = GutenbergAudioUploadProcessor( + mediaUploadID: gutenbergMediaUploadID, + serverMediaID: mediaID, + remoteURLString: remoteURLStr + ) gutenbergProcessors.append(gutenbergAudioProcessor) } else if let remoteURL = URL(string: remoteURLStr) { let documentTitle = remoteURL.lastPathComponent - let documentUploadProcessor = DocumentUploadProcessor(mediaUploadID: mediaUploadID, remoteURLString: remoteURLStr, title: documentTitle) + let documentUploadProcessor = DocumentUploadProcessor( + mediaUploadID: mediaUploadID, + remoteURLString: remoteURLStr, + title: documentTitle + ) aztecProcessors.append(documentUploadProcessor) } // Gutenberg processors need to run first because they are more specific/and target only content inside specific blocks gutenbergBlockProcessors.forEach { $0.process(contentBlocks) } postContent = gutenbergProcessors.reduce(postContent) { content, processor -> String in - return processor.process(content) + processor.process(content) } // Aztec processors are next because they are more generic and only worried about HTML tags postContent = aztecProcessors.reduce(postContent) { content, processor -> String in - return processor.process(content) + processor.process(content) } post.content = postContent From 869579e1956edeb648576516f715850751e3c0b7 Mon Sep 17 00:00:00 2001 From: Tony Li Date: Tue, 7 Jul 2026 14:49:23 +1200 Subject: [PATCH 2/5] Add App Intent publishing helpers for posts Adds eligibility, blocker, and update-parameter helpers that let App Intents publish or schedule server-synced posts. Posts with unsaved local edits, pages, local-only posts, and posts the user's role cannot publish are refused up front, using the same capability check and nil-capabilities allowance as the in-app publish affordances, rather than surfacing a raw server rejection after confirmation. Publishing a previously scheduled post moves its date to now through a RemotePostUpdateParameters helper shared with PostCoordinator.save, covering the private-publish case the earlier inline copies missed. The publishable status list is shared between the eligibility check and the fetch predicate. --- .../Models/PostAppIntentPublishingTests.swift | 266 ++++++++++++++++++ .../AbstractPost+AppIntentsPublishing.swift | 87 ++++++ ...emotePostUpdateParameters+Publishing.swift | 18 ++ 3 files changed, 371 insertions(+) create mode 100644 Tests/KeystoneTests/Tests/Models/PostAppIntentPublishingTests.swift create mode 100644 WordPress/Classes/Models/AbstractPost+AppIntentsPublishing.swift create mode 100644 WordPress/Classes/Models/RemotePostUpdateParameters+Publishing.swift diff --git a/Tests/KeystoneTests/Tests/Models/PostAppIntentPublishingTests.swift b/Tests/KeystoneTests/Tests/Models/PostAppIntentPublishingTests.swift new file mode 100644 index 000000000000..7d2de06f31cc --- /dev/null +++ b/Tests/KeystoneTests/Tests/Models/PostAppIntentPublishingTests.swift @@ -0,0 +1,266 @@ +import Foundation +import Testing +import WordPressKit + +@testable import WordPress +@testable import WordPressData + +@MainActor +@Suite("Post app intent publishing eligibility search") +struct PostAppIntentPublishingSearchTests { + @Test("returns drafts, pending, and scheduled posts but not published or trashed ones") + func returnsOnlyPublishableStatuses() { + let context = ContextManager.forTesting().mainContext + let blog = BlogBuilder(context).with(dotComID: 111).build() + let draft = PostBuilder(context, blog: blog).drafted().with(title: "Draft").build() + draft.postID = 1 + let pending = PostBuilder(context, blog: blog).pending().with(title: "Pending").build() + pending.postID = 2 + let scheduled = PostBuilder(context, blog: blog).scheduled().with(title: "Scheduled").build() + scheduled.postID = 3 + let published = PostBuilder(context, blog: blog).published().with(title: "Published").build() + published.postID = 4 + let trashed = PostBuilder(context, blog: blog).trashed().with(title: "Trashed").build() + trashed.postID = 5 + + let results = Post.recentForAppIntentPublishing(in: context) + + #expect(Set(results) == Set([draft, pending, scheduled])) + } + + @Test("excludes pages") + func excludesPages() { + let context = ContextManager.forTesting().mainContext + let blog = BlogBuilder(context).with(dotComID: 111).build() + let page = PageBuilder(context).build() + page.blog = blog + page.postTitle = "Draft page" + page.status = .draft + page.postID = 1 + + #expect(Post.recentForAppIntentPublishing(in: context) == []) + } + + @Test("excludes local-only posts") + func excludesLocalOnlyPosts() { + let context = ContextManager.forTesting().mainContext + let blog = BlogBuilder(context).with(dotComID: 111).build() + PostBuilder(context, blog: blog).drafted().with(title: "Local only").build() + + #expect(Post.recentForAppIntentPublishing(in: context) == []) + } + + @Test("excludes posts with unsaved local edits") + func excludesPostsWithUnsavedEdits() { + let context = ContextManager.forTesting().mainContext + let blog = BlogBuilder(context).with(dotComID: 111).build() + let edited = PostBuilder(context, blog: blog).drafted().with(title: "Edited").build() + edited.postID = 1 + _ = edited.createRevision() + + #expect(Post.recentForAppIntentPublishing(in: context) == []) + } + + @Test("returns recent posts, most recently modified first") + func returnsRecentPostsFirst() { + let context = ContextManager.forTesting().mainContext + let blog = BlogBuilder(context).with(dotComID: 111).build() + let older = PostBuilder(context, blog: blog).drafted().with(title: "Older") + .with(dateModified: Date(timeIntervalSince1970: 1000)).build() + older.postID = 1 + let newer = PostBuilder(context, blog: blog).drafted().with(title: "Newer") + .with(dateModified: Date(timeIntervalSince1970: 2000)).build() + newer.postID = 2 + + #expect(Post.recentForAppIntentPublishing(in: context) == [newer, older]) + } + + @Test("caps the number of results") + func capsResults() { + let context = ContextManager.forTesting().mainContext + let blog = BlogBuilder(context).with(dotComID: 111).build() + for index in 1...3 { + let post = PostBuilder(context, blog: blog).drafted().with(title: "Post \(index)").build() + post.postID = NSNumber(value: index) + } + + #expect(Post.recentForAppIntentPublishing(limit: 2, in: context).count == 2) + } +} + +@MainActor +@Suite("Post app intent publishing blocker") +struct PostAppIntentPublishingBlockerTests { + @Test("a server-synced draft, pending, or scheduled post has no blocker") + func publishablePostsHaveNoBlocker() { + let context = ContextManager.forTesting().mainContext + let blog = BlogBuilder(context).with(dotComID: 111).build() + let draft = PostBuilder(context, blog: blog).drafted().build() + draft.postID = 1 + let pending = PostBuilder(context, blog: blog).pending().build() + pending.postID = 2 + let scheduled = PostBuilder(context, blog: blog).scheduled().build() + scheduled.postID = 3 + + #expect(draft.appIntentPublishingBlocker == nil) + #expect(pending.appIntentPublishingBlocker == nil) + #expect(scheduled.appIntentPublishingBlocker == nil) + } + + @Test("a page is blocked") + func pageIsBlocked() { + let context = ContextManager.forTesting().mainContext + let blog = BlogBuilder(context).with(dotComID: 111).build() + let page = PageBuilder(context).build() + page.blog = blog + page.status = .draft + page.postID = 1 + + #expect(page.appIntentPublishingBlocker == .isPage) + } + + @Test("a local-only post is blocked") + func localOnlyPostIsBlocked() { + let context = ContextManager.forTesting().mainContext + let blog = BlogBuilder(context).with(dotComID: 111).build() + let post = PostBuilder(context, blog: blog).drafted().build() + + #expect(post.appIntentPublishingBlocker == .localOnly) + } + + @Test("a post with unsaved local edits is blocked") + func postWithUnsavedEditsIsBlocked() { + let context = ContextManager.forTesting().mainContext + let blog = BlogBuilder(context).with(dotComID: 111).build() + let post = PostBuilder(context, blog: blog).drafted().build() + post.postID = 1 + _ = post.createRevision() + + #expect(post.appIntentPublishingBlocker == .hasUnsavedChanges) + } + + @Test("a user without the publish capability is blocked") + func missingPublishCapabilityIsBlocked() { + let context = ContextManager.forTesting().mainContext + let blog = BlogBuilder(context).with(dotComID: 111).with(capabilities: [.editPosts]).build() + let post = PostBuilder(context, blog: blog).drafted().build() + post.postID = 1 + + #expect(post.appIntentPublishingBlocker == .publishingNotAllowed) + } + + @Test("a user with the publish capability has no blocker") + func publishCapabilityHasNoBlocker() { + let context = ContextManager.forTesting().mainContext + let blog = BlogBuilder(context).with(dotComID: 111).with(capabilities: [.publishPosts]).build() + let post = PostBuilder(context, blog: blog).drafted().build() + post.postID = 1 + + #expect(post.appIntentPublishingBlocker == nil) + } + + @Test("a site without loaded capabilities stays allowed") + func nilCapabilitiesHasNoBlocker() { + let context = ContextManager.forTesting().mainContext + let blog = BlogBuilder(context).with(dotComID: 111).build() + let post = PostBuilder(context, blog: blog).drafted().build() + post.postID = 1 + + #expect(blog.capabilities == nil) + #expect(post.appIntentPublishingBlocker == nil) + } + + @Test("a published or trashed post is blocked") + func nonPublishableStatusIsBlocked() { + let context = ContextManager.forTesting().mainContext + let blog = BlogBuilder(context).with(dotComID: 111).build() + let published = PostBuilder(context, blog: blog).published().build() + published.postID = 1 + let trashed = PostBuilder(context, blog: blog).trashed().build() + trashed.postID = 2 + + #expect(published.appIntentPublishingBlocker == .notPublishable) + #expect(trashed.appIntentPublishingBlocker == .notPublishable) + } +} + +@MainActor +@Suite("Post app intent publishing parameters") +struct PostAppIntentPublishingParametersTests { + @Test("publishing a draft sets the publish status and keeps the date") + func publishParametersForDraft() { + let context = ContextManager.forTesting().mainContext + let blog = BlogBuilder(context).with(dotComID: 111).build() + let draft = PostBuilder(context, blog: blog).drafted().build() + draft.postID = 1 + + let changes = draft.appIntentPublishParameters(now: Date(timeIntervalSince1970: 5000)) + + #expect(changes.status == Post.Status.publish.rawValue) + #expect(changes.date == nil) + } + + @Test("publishing a scheduled post moves its date to now so it publishes immediately") + func publishParametersForScheduledPost() { + let context = ContextManager.forTesting().mainContext + let blog = BlogBuilder(context).with(dotComID: 111).build() + let scheduled = PostBuilder(context, blog: blog).scheduled().build() + scheduled.postID = 1 + let now = Date(timeIntervalSince1970: 5000) + + let changes = scheduled.appIntentPublishParameters(now: now) + + #expect(changes.status == Post.Status.publish.rawValue) + #expect(changes.date == now) + } + + @Test("scheduling sets the scheduled status and the given date") + func scheduleParameters() { + let context = ContextManager.forTesting().mainContext + let blog = BlogBuilder(context).with(dotComID: 111).build() + let draft = PostBuilder(context, blog: blog).drafted().build() + draft.postID = 1 + let date = Date(timeIntervalSince1970: 90000) + + let changes = draft.appIntentScheduleParameters(for: date) + + #expect(changes.status == Post.Status.scheduled.rawValue) + #expect(changes.date == date) + } +} + +@Suite("Remote post update parameters publish date coercion") +struct RemotePostUpdateParametersPublishingTests { + @Test("publishing privately from scheduled also moves the date to now") + func privatePublishFromScheduledMovesDate() { + var changes = RemotePostUpdateParameters() + changes.status = Post.Status.publishPrivate.rawValue + let now = Date(timeIntervalSince1970: 5000) + + changes.setDateForImmediatePublishIfNeeded(previousStatus: .scheduled, now: now) + + #expect(changes.date == now) + } + + @Test("an explicitly chosen publish date is preserved") + func explicitDateIsPreserved() { + var changes = RemotePostUpdateParameters() + changes.status = Post.Status.publish.rawValue + let date = Date(timeIntervalSince1970: 90000) + changes.date = date + + changes.setDateForImmediatePublishIfNeeded(previousStatus: .scheduled, now: Date(timeIntervalSince1970: 5000)) + + #expect(changes.date == date) + } + + @Test("a post that was not scheduled keeps a nil date") + func nonScheduledPreviousStatusKeepsNilDate() { + var changes = RemotePostUpdateParameters() + changes.status = Post.Status.publish.rawValue + + changes.setDateForImmediatePublishIfNeeded(previousStatus: .draft, now: Date(timeIntervalSince1970: 5000)) + + #expect(changes.date == nil) + } +} diff --git a/WordPress/Classes/Models/AbstractPost+AppIntentsPublishing.swift b/WordPress/Classes/Models/AbstractPost+AppIntentsPublishing.swift new file mode 100644 index 000000000000..64f3c59a5d05 --- /dev/null +++ b/WordPress/Classes/Models/AbstractPost+AppIntentsPublishing.swift @@ -0,0 +1,87 @@ +import Foundation +import WordPressData +import WordPressKit + +extension AbstractPost { + /// Why an App Intent cannot publish or schedule the post. + enum AppIntentPublishingBlocker { + case isPage + case localOnly + case hasUnsavedChanges + case notPublishable + case publishingNotAllowed + } + + /// The statuses an App Intent can publish or schedule a post from, shared + /// between the eligibility check and the picker fetch so they cannot + /// drift apart. + static let appIntentPublishableStatuses: [Status] = [.draft, .pending, .scheduled] + + /// Returns the reason an App Intent must refuse to publish or schedule + /// this post, or `nil` when it can proceed. + /// + /// Posts with unsaved local edits are refused rather than synced: an + /// intent publishing half-finished editor changes is never what the + /// user asked for. + var appIntentPublishingBlocker: AppIntentPublishingBlocker? { + if self is Page { + return .isPage + } + guard hasRemote() else { + return .localOnly + } + if hasRevision() { + return .hasUnsavedChanges + } + guard let status, Self.appIntentPublishableStatuses.contains(status) else { + return .notPublishable + } + // Sites without loaded capabilities (self-hosted) stay allowed, the + // same trade-off the in-app publish affordances make. + if blog.capabilities != nil && !blog.isPublishingPostsAllowed() { + return .publishingNotAllowed + } + return nil + } + + /// The delta that publishes the post immediately. + func appIntentPublishParameters(now: Date = .now) -> RemotePostUpdateParameters { + var changes = RemotePostUpdateParameters() + changes.status = Post.Status.publish.rawValue + changes.setDateForImmediatePublishIfNeeded(previousStatus: status, now: now) + return changes + } + + /// The delta that schedules the post for the given date. + func appIntentScheduleParameters(for date: Date) -> RemotePostUpdateParameters { + var changes = RemotePostUpdateParameters() + changes.status = Post.Status.scheduled.rawValue + changes.date = date + return changes + } +} + +extension Post { + /// Returns the most recently modified posts an App Intent can offer for + /// publishing or scheduling. Only server-synced originals in a + /// publishable status with no unsaved local edits qualify; see + /// `appIntentPublishingBlocker` for why the rest are excluded. + /// + /// The result is capped: `DynamicOptionsProvider` has no search hook on + /// iOS 17, so this feeds a fixed picker of recents rather than a + /// searchable index. + static func recentForAppIntentPublishing( + limit: Int = 20, + in context: NSManagedObjectContext + ) -> [Post] { + let statuses = appIntentPublishableStatuses.map(\.rawValue) + let request = NSFetchRequest(entityName: Post.entityName()) + request.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [ + NSPredicate(format: "original = NULL AND revision = NULL AND postID > 0"), + NSPredicate(format: "status IN %@", statuses) + ]) + request.sortDescriptors = [NSSortDescriptor(key: #keyPath(AbstractPost.dateModified), ascending: false)] + request.fetchLimit = limit + return (try? context.fetch(request)) ?? [] + } +} diff --git a/WordPress/Classes/Models/RemotePostUpdateParameters+Publishing.swift b/WordPress/Classes/Models/RemotePostUpdateParameters+Publishing.swift new file mode 100644 index 000000000000..ff72f4276dce --- /dev/null +++ b/WordPress/Classes/Models/RemotePostUpdateParameters+Publishing.swift @@ -0,0 +1,18 @@ +import Foundation +import WordPressData +import WordPressKit + +extension RemotePostUpdateParameters { + /// If the post was previously scheduled and these changes publish it + /// without specifying a new date, sets the date to `now`; otherwise the + /// server would leave the post scheduled instead of publishing it. + mutating func setDateForImmediatePublishIfNeeded(previousStatus: BasePost.Status?, now: Date = .now) { + guard status == Post.Status.publish.rawValue || status == Post.Status.publishPrivate.rawValue, + previousStatus == .scheduled, + date == nil + else { + return + } + date = now + } +} From e115a2ed9c01610fe7f6e5467b8ea2c68c037eb9 Mon Sep 17 00:00:00 2001 From: Tony Li Date: Tue, 7 Jul 2026 14:49:23 +1200 Subject: [PATCH 3/5] Prepare PostCoordinator side effects and cleanup for App Intent saves A save that skips the UIKit flow used to skip the coordinator's non-UI side effects too: the dashboard notifications that refresh the posts cards, the app-rating significant event, and the post-save error analytics. The dormant didPublish helper now owns the success block so non-UI callers can emit them, leaving only the alert and notice UI in the UIKit path. The not-found cleanup from the in-app alert flow is shared so a post permanently deleted on the server no longer survives as a local ghost, and it now also removes the post's Spotlight entry, which was previously leaked. The scheduled-post publish date coercion moves to the shared RemotePostUpdateParameters helper. --- .../Classes/Services/PostCoordinator.swift | 35 ++++++++----------- 1 file changed, 14 insertions(+), 21 deletions(-) diff --git a/WordPress/Classes/Services/PostCoordinator.swift b/WordPress/Classes/Services/PostCoordinator.swift index b643ff389d4d..01bb1826fd76 100644 --- a/WordPress/Classes/Services/PostCoordinator.swift +++ b/WordPress/Classes/Services/PostCoordinator.swift @@ -96,8 +96,14 @@ class PostCoordinator: NSObject { try await save(post, changes: parameters) } + /// Emits the side effects of a post reaching the scheduled or published + /// state: dashboard notifications, Spotlight indexing, and the + /// app-rating significant event. @MainActor - private func didPublish(_ post: AbstractPost) { + func didPublish(_ post: AbstractPost, previousStatus: BasePost.Status?) { + guard previousStatus != post.status, post.isStatus(in: [.scheduled, .publish]) else { + return + } if post.status == .scheduled { notifyNewPostScheduled() } else if post.status == .publish { @@ -118,29 +124,13 @@ class PostCoordinator: NSObject { let previousStatus = post.status var changes = changes ?? .init() - - // If the post was previously scheduled and the user wants to publish - // it without specifying a new publish date, we have to send `.now` - // to ensure it gets published immediatelly. - if (changes.status == Post.Status.publish.rawValue || changes.status == Post.Status.publishPrivate.rawValue) - && previousStatus == .scheduled && changes.date == nil - { - changes.date = .now - } + changes.setDateForImmediatePublishIfNeeded(previousStatus: previousStatus) do { let repository = PostRepository(coreDataStack: coreDataStack) try await repository.save(post, changes: changes) - if previousStatus != post.status && post.isStatus(in: [.scheduled, .publish]) { - if post.status == .scheduled { - notifyNewPostScheduled() - } else if post.status == .publish { - notifyNewPostPublished() - } - SearchManager.shared.indexItem(post) - AppRatingUtility.shared.incrementSignificantEvent() - } + didPublish(post, previousStatus: previousStatus) show(PostCoordinator.makeUploadSuccessNotice(for: post, previousStatus: previousStatus)) return post } catch { @@ -193,7 +183,7 @@ class PostCoordinator: NSObject { topViewController.present(alert, animated: true) } - private func trackError(_ error: Error, operation: String, post: AbstractPost) { + func trackError(_ error: Error, operation: String, post: AbstractPost) { DDLogError("post-coordinator-\(operation)-failed: \(error)") if let error = error as? TrackableErrorProtocol, var userInfo = error.getTrackingUserInfo() { @@ -230,7 +220,10 @@ class PostCoordinator: NSObject { startSync(for: post) // Clears the error associated with the post } - private func handlePermanentlyDeleted(_ post: AbstractPost) { + /// Removes the local copy of a post that was permanently deleted on the + /// server, along with its Spotlight entry. + func handlePermanentlyDeleted(_ post: AbstractPost) { + SearchManager.shared.deleteSearchableItem(post) let context = coreDataStack.mainContext context.deleteObject(post) ContextManager.shared.saveContextAndWait(context) From a2c3e947f350ecd6af0473bf8aeff1461d44072b Mon Sep 17 00:00:00 2001 From: Tony Li Date: Tue, 7 Jul 2026 14:49:23 +1200 Subject: [PATCH 4/5] Add publish and schedule post App Intents to the Jetpack app Two side-effecting intents that ask for confirmation before acting and appear as App Shortcuts with Siri phrases; the post picker offers only server-synced posts in a publishable status, and a failed save surfaces the server's own error message instead of a generic failure. Eligibility and the schedule intent's future-date guard run again after requestConfirmation returns, so state that changed while the dialog was pending, most notably an editor revision created in the app, is not silently published. The result dialog reflects the post's status after the save, because the server publishes immediately when the chosen date is no longer far enough in the future. The save path emits the PostCoordinator side effects and cleans up server-deleted posts, and the strings use the ios-appintents. GlotPress keys. --- .../AppIntents/AppIntentPostSaving.swift | 41 +++++++++ .../AppIntents/AppIntentPublishError.swift | 70 +++++++++++++++ .../AppIntents/JetpackAppShortcuts.swift | 21 +++++ .../AppIntents/PublishPostIntent.swift | 75 ++++++++++++++++ .../PublishablePostOptionsProvider.swift | 18 ++++ .../AppIntents/SchedulePostIntent.swift | 89 +++++++++++++++++++ .../Resources/en.lproj/AppShortcuts.strings | 12 +++ .../en.lproj/Localizable.strings | 63 +++++++++++++ 8 files changed, 389 insertions(+) create mode 100644 Sources/Jetpack/AppIntents/AppIntentPostSaving.swift create mode 100644 Sources/Jetpack/AppIntents/AppIntentPublishError.swift create mode 100644 Sources/Jetpack/AppIntents/PublishPostIntent.swift create mode 100644 Sources/Jetpack/AppIntents/PublishablePostOptionsProvider.swift create mode 100644 Sources/Jetpack/AppIntents/SchedulePostIntent.swift diff --git a/Sources/Jetpack/AppIntents/AppIntentPostSaving.swift b/Sources/Jetpack/AppIntents/AppIntentPostSaving.swift new file mode 100644 index 000000000000..2400293c1fcb --- /dev/null +++ b/Sources/Jetpack/AppIntents/AppIntentPostSaving.swift @@ -0,0 +1,41 @@ +import Foundation +import WordPressData +import WordPressKit + +/// Saves a post status change on behalf of an App Intent. +/// +/// Mirrors the `PostCoordinator.save` orchestration (sync pausing, error +/// analytics, deleted-post cleanup, and the post-publish side effects) +/// except for its UIKit pieces, which would present alerts and notices on +/// top of the intent's own dialogs. +enum AppIntentPostSaving { + @MainActor + static func save(_ post: AbstractPost, changes: RemotePostUpdateParameters) async throws { + let coordinator = PostCoordinator.shared + await coordinator.pauseSyncing(for: post) + defer { coordinator.resumeSyncing(for: post) } + + let previousStatus = post.status + do { + try await PostRepository().save(post, changes: changes) + } catch { + // Same operation string as PostCoordinator.save: the failure + // surface is the same call, and analytics funnel on it. + coordinator.trackError(error, operation: "post-save", post: post) + // A post deleted on the server can never be saved again; drop + // the local copy (as the in-app flow does) so the intents stop + // offering it. The error message carries the post title. + if let saveError = error as? PostRepository.PostSaveError, case .deleted = saveError { + coordinator.handlePermanentlyDeleted(post) + } + // For WP.com endpoint errors the localized description is the + // server's own message (e.g. why publishing was rejected). + throw AppIntentPublishError.saveFailed(reason: error.localizedDescription) + } + coordinator.didPublish(post, previousStatus: previousStatus) + // Unconditional on purpose: re-scheduling an already-scheduled post + // does not pass didPublish's status-changed gate but must still + // refresh the Spotlight entry; the repeated upsert is idempotent. + SearchManager.shared.indexItem(post) + } +} diff --git a/Sources/Jetpack/AppIntents/AppIntentPublishError.swift b/Sources/Jetpack/AppIntents/AppIntentPublishError.swift new file mode 100644 index 000000000000..dc7184428cab --- /dev/null +++ b/Sources/Jetpack/AppIntents/AppIntentPublishError.swift @@ -0,0 +1,70 @@ +import AppIntents +import Foundation +import WordPressData + +/// Errors surfaced to the system when a publish or schedule intent cannot act. +enum AppIntentPublishError: Error, CustomLocalizedStringResourceConvertible { + case postNotFound + case isPage + case hasUnsavedChanges + case notPublishable + case publishingNotAllowed + case dateMustBeInFuture + + /// The save was rejected; `reason` carries the server's own message + /// (e.g. the `rest_cannot_publish` explanation) rather than a generic + /// failure string. + case saveFailed(reason: String) + + var localizedStringResource: LocalizedStringResource { + switch self { + case .postNotFound: + return LocalizedStringResource( + "ios-appintents.publishError.postNotFound", + defaultValue: "The post could not be found." + ) + case .isPage: + return LocalizedStringResource( + "ios-appintents.publishError.isPage", + defaultValue: "Publishing pages is not supported." + ) + case .hasUnsavedChanges: + return LocalizedStringResource( + "ios-appintents.publishError.hasUnsavedChanges", + defaultValue: "The post has unsaved changes. Open it in the app to publish it." + ) + case .notPublishable: + return LocalizedStringResource( + "ios-appintents.publishError.notPublishable", + defaultValue: "The post is already published or cannot be published." + ) + case .publishingNotAllowed: + return LocalizedStringResource( + "ios-appintents.publishError.publishingNotAllowed", + defaultValue: "You don't have permission to publish posts on this site." + ) + case .dateMustBeInFuture: + return LocalizedStringResource( + "ios-appintents.publishError.dateMustBeInFuture", + defaultValue: "The publish date must be in the future." + ) + case .saveFailed(let reason): + return "\(reason)" + } + } +} + +extension AppIntentPublishError { + init(_ blocker: AbstractPost.AppIntentPublishingBlocker) { + switch blocker { + case .isPage: + self = .isPage + case .hasUnsavedChanges: + self = .hasUnsavedChanges + case .localOnly, .notPublishable: + self = .notPublishable + case .publishingNotAllowed: + self = .publishingNotAllowed + } + } +} diff --git a/Sources/Jetpack/AppIntents/JetpackAppShortcuts.swift b/Sources/Jetpack/AppIntents/JetpackAppShortcuts.swift index f95107888648..e07c70e8163e 100644 --- a/Sources/Jetpack/AppIntents/JetpackAppShortcuts.swift +++ b/Sources/Jetpack/AppIntents/JetpackAppShortcuts.swift @@ -43,5 +43,26 @@ struct JetpackAppShortcuts: AppShortcutsProvider { shortTitle: LocalizedStringResource("ios-appintents.openReader.shortTitle", defaultValue: "Reader"), systemImageName: "book" ) + AppShortcut( + intent: PublishPostIntent(), + phrases: [ + "Publish a post in \(.applicationName)", + "Publish a draft in \(.applicationName)" + ], + shortTitle: LocalizedStringResource("ios-appintents.publishPost.shortTitle", defaultValue: "Publish Post"), + systemImageName: "paperplane" + ) + AppShortcut( + intent: SchedulePostIntent(), + phrases: [ + "Schedule a post in \(.applicationName)", + "Schedule a draft in \(.applicationName)" + ], + shortTitle: LocalizedStringResource( + "ios-appintents.schedulePost.shortTitle", + defaultValue: "Schedule Post" + ), + systemImageName: "calendar.badge.clock" + ) } } diff --git a/Sources/Jetpack/AppIntents/PublishPostIntent.swift b/Sources/Jetpack/AppIntents/PublishPostIntent.swift new file mode 100644 index 000000000000..653dbe76cf7b --- /dev/null +++ b/Sources/Jetpack/AppIntents/PublishPostIntent.swift @@ -0,0 +1,75 @@ +import AppIntents +import Foundation +import WordPressData + +/// Publishes a draft, pending, or scheduled post after the user confirms. +struct PublishPostIntent: AppIntent { + static let title = LocalizedStringResource("ios-appintents.publishPost.title", defaultValue: "Publish Post") + static let description = IntentDescription( + LocalizedStringResource( + "ios-appintents.publishPost.description", + defaultValue: "Publishes one of your draft, pending, or scheduled posts." + ) + ) + + @Parameter( + title: LocalizedStringResource("ios-appintents.publishPost.postParameter", defaultValue: "Post"), + optionsProvider: PublishablePostOptionsProvider() + ) + var post: PostEntity + + @MainActor + func perform() async throws -> some IntentResult & ProvidesDialog & ReturnsValue { + let context = ContextManager.shared.mainContext + guard let target = AbstractPost.forAppIntent(identifier: post.id, in: context) else { + throw AppIntentPublishError.postNotFound + } + if let blocker = target.appIntentPublishingBlocker { + throw AppIntentPublishError(blocker) + } + + let title = target.titleForDisplay() + // TODO: migrate to requestConfirmation(conditions:actionName:dialog:) once the deployment target reaches iOS 18. + try await requestConfirmation( + result: .result( + dialog: IntentDialog( + LocalizedStringResource( + "ios-appintents.publishPost.confirmationDialog", + defaultValue: "Publish “\(title)”?" + ) + ) + ), + confirmationActionName: .post + ) + // The post can change while the confirmation dialog is up (e.g. the + // editor creates an unsaved revision); re-check before acting. + if let blocker = target.appIntentPublishingBlocker { + throw AppIntentPublishError(blocker) + } + + try await AppIntentPostSaving.save(target, changes: target.appIntentPublishParameters()) + + guard let updated = PostEntity(post: target) else { + throw AppIntentPublishError.postNotFound + } + // A post with a future publish date comes back scheduled rather than + // published; the dialog reflects what the server actually did. + let dialog: IntentDialog + if target.status == .scheduled, let date = target.dateCreated { + dialog = IntentDialog( + LocalizedStringResource( + "ios-appintents.publishPost.scheduledDialog", + defaultValue: "Scheduled “\(title)” for \(date.formatted(date: .abbreviated, time: .shortened))." + ) + ) + } else { + dialog = IntentDialog( + LocalizedStringResource( + "ios-appintents.publishPost.publishedDialog", + defaultValue: "Published “\(title)”." + ) + ) + } + return .result(value: updated, dialog: dialog) + } +} diff --git a/Sources/Jetpack/AppIntents/PublishablePostOptionsProvider.swift b/Sources/Jetpack/AppIntents/PublishablePostOptionsProvider.swift new file mode 100644 index 000000000000..14c5b42b4b1a --- /dev/null +++ b/Sources/Jetpack/AppIntents/PublishablePostOptionsProvider.swift @@ -0,0 +1,18 @@ +import AppIntents +import Foundation +import WordPressData + +/// Offers only posts that can actually be published or scheduled, unlike the +/// entity's default query which covers all posts and pages. +/// +/// This provider only feeds the Shortcuts-editor picker. Free-text +/// resolution (Siri voice, "Ask Each Time") still goes through the entity's +/// default query, so an unpublishable pick is possible there and is rejected +/// by the intents with a specific blocker error instead. +struct PublishablePostOptionsProvider: DynamicOptionsProvider { + @MainActor + func results() async throws -> [PostEntity] { + let context = ContextManager.shared.mainContext + return Post.recentForAppIntentPublishing(in: context).compactMap { PostEntity(post: $0) } + } +} diff --git a/Sources/Jetpack/AppIntents/SchedulePostIntent.swift b/Sources/Jetpack/AppIntents/SchedulePostIntent.swift new file mode 100644 index 000000000000..0e152a370413 --- /dev/null +++ b/Sources/Jetpack/AppIntents/SchedulePostIntent.swift @@ -0,0 +1,89 @@ +import AppIntents +import Foundation +import WordPressData + +/// Schedules a draft, pending, or already-scheduled post for a future date +/// after the user confirms. +struct SchedulePostIntent: AppIntent { + static let title = LocalizedStringResource("ios-appintents.schedulePost.title", defaultValue: "Schedule Post") + static let description = IntentDescription( + LocalizedStringResource( + "ios-appintents.schedulePost.description", + defaultValue: "Schedules one of your draft, pending, or scheduled posts to publish at a future date." + ) + ) + + @Parameter( + title: LocalizedStringResource("ios-appintents.schedulePost.postParameter", defaultValue: "Post"), + optionsProvider: PublishablePostOptionsProvider() + ) + var post: PostEntity + + @Parameter( + title: LocalizedStringResource("ios-appintents.schedulePost.dateParameter", defaultValue: "Publish Date") + ) + var date: Date + + @MainActor + func perform() async throws -> some IntentResult & ProvidesDialog & ReturnsValue { + let context = ContextManager.shared.mainContext + guard let target = AbstractPost.forAppIntent(identifier: post.id, in: context) else { + throw AppIntentPublishError.postNotFound + } + if let blocker = target.appIntentPublishingBlocker { + throw AppIntentPublishError(blocker) + } + guard date > .now else { + throw AppIntentPublishError.dateMustBeInFuture + } + + let title = target.titleForDisplay() + let formattedDate = date.formatted(date: .abbreviated, time: .shortened) + // TODO: migrate to requestConfirmation(conditions:actionName:dialog:) once the deployment target reaches iOS 18. + try await requestConfirmation( + result: .result( + dialog: IntentDialog( + LocalizedStringResource( + "ios-appintents.schedulePost.confirmationDialog", + defaultValue: "Schedule “\(title)” for \(formattedDate)?" + ) + ) + ), + confirmationActionName: .set + ) + // The post can change and the chosen date can lapse while the + // confirmation dialog is up; re-check both before acting. + if let blocker = target.appIntentPublishingBlocker { + throw AppIntentPublishError(blocker) + } + guard date > .now else { + throw AppIntentPublishError.dateMustBeInFuture + } + + try await AppIntentPostSaving.save(target, changes: target.appIntentScheduleParameters(for: date)) + + guard let updated = PostEntity(post: target) else { + throw AppIntentPublishError.postNotFound + } + // The server publishes immediately when the date is no longer far + // enough in the future; the dialog reflects what it actually did. + let dialog: IntentDialog + if target.status == .publish { + dialog = IntentDialog( + LocalizedStringResource( + "ios-appintents.schedulePost.publishedDialog", + defaultValue: "Published “\(title)”." + ) + ) + } else { + let scheduledDate = (target.dateCreated ?? date).formatted(date: .abbreviated, time: .shortened) + dialog = IntentDialog( + LocalizedStringResource( + "ios-appintents.schedulePost.scheduledDialog", + defaultValue: "Scheduled “\(title)” for \(scheduledDate)." + ) + ) + } + return .result(value: updated, dialog: dialog) + } +} diff --git a/Sources/Jetpack/Resources/en.lproj/AppShortcuts.strings b/Sources/Jetpack/Resources/en.lproj/AppShortcuts.strings index 34d8ecdd9937..226beb1dc13d 100644 --- a/Sources/Jetpack/Resources/en.lproj/AppShortcuts.strings +++ b/Sources/Jetpack/Resources/en.lproj/AppShortcuts.strings @@ -37,3 +37,15 @@ /* Siri phrase to open the Reader tab. ${applicationName} is the app's name. */ "Open Reader in ${applicationName}" = "Open Reader in ${applicationName}"; + +/* Siri phrase to publish a post. ${applicationName} is the app's name. */ +"Publish a post in ${applicationName}" = "Publish a post in ${applicationName}"; + +/* Siri phrase to publish a post. ${applicationName} is the app's name. */ +"Publish a draft in ${applicationName}" = "Publish a draft in ${applicationName}"; + +/* Siri phrase to schedule a post. ${applicationName} is the app's name. */ +"Schedule a post in ${applicationName}" = "Schedule a post in ${applicationName}"; + +/* Siri phrase to schedule a post. ${applicationName} is the app's name. */ +"Schedule a draft in ${applicationName}" = "Schedule a draft in ${applicationName}"; diff --git a/WordPress/JetpackAppIntents/en.lproj/Localizable.strings b/WordPress/JetpackAppIntents/en.lproj/Localizable.strings index cc8dcb97c82e..622c0c240940 100644 --- a/WordPress/JetpackAppIntents/en.lproj/Localizable.strings +++ b/WordPress/JetpackAppIntents/en.lproj/Localizable.strings @@ -87,3 +87,66 @@ /* Error shown by Siri or the Shortcuts app when the post an App Intent should act on no longer exists. */ "openError.postNotFound" = "The post could not be found."; + +/* Title of the App Intent that publishes a post. Shown in the Shortcuts app and Spotlight. */ +"publishPost.title" = "Publish Post"; + +/* Description of the App Intent that publishes a post. Shown in the Shortcuts app. */ +"publishPost.description" = "Publishes one of your draft, pending, or scheduled posts."; + +/* Label of the post parameter of the Publish Post App Intent. Shown in the Shortcuts app. */ +"publishPost.postParameter" = "Post"; + +/* Short title of the Publish Post shortcut tile in Spotlight and the Shortcuts app. */ +"publishPost.shortTitle" = "Publish Post"; + +/* Confirmation dialog shown by Siri or the Shortcuts app before publishing. %1$@ is the post title. */ +"publishPost.confirmationDialog" = "Publish “%1$@”?"; + +/* Dialog shown by Siri or the Shortcuts app after a post was published. %1$@ is the post title. */ +"publishPost.publishedDialog" = "Published “%1$@”."; + +/* Dialog shown by Siri or the Shortcuts app when publishing a post scheduled it instead because its publish date is in the future. %1$@ is the post title, %2$@ the formatted publish date. */ +"publishPost.scheduledDialog" = "Scheduled “%1$@” for %2$@."; + +/* Title of the App Intent that schedules a post. Shown in the Shortcuts app and Spotlight. */ +"schedulePost.title" = "Schedule Post"; + +/* Description of the App Intent that schedules a post. Shown in the Shortcuts app. */ +"schedulePost.description" = "Schedules one of your draft, pending, or scheduled posts to publish at a future date."; + +/* Label of the post parameter of the Schedule Post App Intent. Shown in the Shortcuts app. */ +"schedulePost.postParameter" = "Post"; + +/* Label of the date parameter of the Schedule Post App Intent. Shown in the Shortcuts app. */ +"schedulePost.dateParameter" = "Publish Date"; + +/* Short title of the Schedule Post shortcut tile in Spotlight and the Shortcuts app. */ +"schedulePost.shortTitle" = "Schedule Post"; + +/* Confirmation dialog shown by Siri or the Shortcuts app before scheduling. %1$@ is the post title, %2$@ the formatted publish date. */ +"schedulePost.confirmationDialog" = "Schedule “%1$@” for %2$@?"; + +/* Dialog shown by Siri or the Shortcuts app after a post was scheduled. %1$@ is the post title, %2$@ the formatted publish date. */ +"schedulePost.scheduledDialog" = "Scheduled “%1$@” for %2$@."; + +/* Dialog shown by Siri or the Shortcuts app when scheduling published the post immediately because its date was no longer in the future. %1$@ is the post title. */ +"schedulePost.publishedDialog" = "Published “%1$@”."; + +/* Error shown by Siri or the Shortcuts app when the post a publish or schedule App Intent should act on no longer exists. */ +"publishError.postNotFound" = "The post could not be found."; + +/* Error shown by Siri or the Shortcuts app when the selected item is a page, which publish App Intents do not support. */ +"publishError.isPage" = "Publishing pages is not supported."; + +/* Error shown by Siri or the Shortcuts app when the post has local changes that must be resolved in the app before publishing. */ +"publishError.hasUnsavedChanges" = "The post has unsaved changes. Open it in the app to publish it."; + +/* Error shown by Siri or the Shortcuts app when the post is not in a publishable state. */ +"publishError.notPublishable" = "The post is already published or cannot be published."; + +/* Error shown by Siri or the Shortcuts app when the signed-in user lacks the capability to publish posts on the site. */ +"publishError.publishingNotAllowed" = "You don't have permission to publish posts on this site."; + +/* Error shown by Siri or the Shortcuts app when the chosen schedule date is not in the future. */ +"publishError.dateMustBeInFuture" = "The publish date must be in the future."; From 75cf8f1aaf12fbba9e584a74d85ec1b056a9ba9b Mon Sep 17 00:00:00 2001 From: Tony Li Date: Tue, 7 Jul 2026 15:59:28 +1200 Subject: [PATCH 5/5] Add a release note for the publish and schedule intents --- RELEASE-NOTES.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/RELEASE-NOTES.txt b/RELEASE-NOTES.txt index c0e70981ad57..123cf2afc20d 100644 --- a/RELEASE-NOTES.txt +++ b/RELEASE-NOTES.txt @@ -5,6 +5,7 @@ * [*] [internal] Stats widgets: migrate the site picker from SiriKit to App Intents [#25753] * [*] Add App Shortcuts for creating a post and opening Notifications, Stats, and the Reader from Siri, Spotlight, and the Shortcuts app [#25754] * [*] Posts and Reader posts can now be found and opened from Siri and the Shortcuts app [#25755] +* [*] Publish or schedule a post from Siri and the Shortcuts app, with a confirmation before anything goes live [#25756] 27.0