From e5e397dd651309d0d223949f566192d77b694bd1 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 7586530bcb3067001a09fe9f9aa1f252e5c1006d 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 0bcd97831d9ba62aaa8e6ee9d968fc5bac0cef49 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 ed02b799ef9da6d9837d38fb880e6b2510e1f120 Mon Sep 17 00:00:00 2001 From: Tony Li Date: Wed, 22 Jul 2026 15:29:36 +1200 Subject: [PATCH 4/5] Add publish and schedule post App Intents to the Jetpack app The publish and schedule intents confirm before saving, revalidate eligibility after confirmation, and surface the resulting server state or error. They share the existing PostCoordinator side effects and expose English Siri phrases directly from their App Shortcut declarations. The existing Publish Date translation is reused; the remaining new strings use English defaults. --- .../AppIntents/AppIntentPostSaving.swift | 41 ++++ .../AppIntents/AppIntentPublishError.swift | 86 +++++++ .../AppIntents/JetpackAppShortcuts.swift | 28 +++ .../AppIntents/PublishPostIntent.swift | 96 ++++++++ .../PublishablePostOptionsProvider.swift | 18 ++ .../AppIntents/SchedulePostIntent.swift | 115 +++++++++ .../Jetpack/Resources/AppIntents.xcstrings | 220 ++++++++++++++++++ .../Jetpack/Resources/AppShortcuts.xcstrings | 40 ++++ 8 files changed, 644 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..c625f2ffbc77 --- /dev/null +++ b/Sources/Jetpack/AppIntents/AppIntentPublishError.swift @@ -0,0 +1,86 @@ +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.", + table: "AppIntents", + comment: + "Error shown by Siri or the Shortcuts app when the post a publish or schedule App Intent should act on no longer exists." + ) + case .isPage: + return LocalizedStringResource( + "ios-appintents.publishError.isPage", + defaultValue: "Publishing pages is not supported.", + table: "AppIntents", + comment: + "Error shown by Siri or the Shortcuts app when the selected item is a page, which publish App Intents do not support." + ) + case .hasUnsavedChanges: + return LocalizedStringResource( + "ios-appintents.publishError.hasUnsavedChanges", + defaultValue: "The post has unsaved changes. Open it in the app to publish it.", + table: "AppIntents", + comment: + "Error shown by Siri or the Shortcuts app when the post has local changes that must be resolved in the app before publishing." + ) + case .notPublishable: + return LocalizedStringResource( + "ios-appintents.publishError.notPublishable", + defaultValue: "The post is already published or cannot be published.", + table: "AppIntents", + comment: "Error shown by Siri or the Shortcuts app when the post is not in a publishable state." + ) + case .publishingNotAllowed: + return LocalizedStringResource( + "ios-appintents.publishError.publishingNotAllowed", + defaultValue: "You don't have permission to publish posts on this site.", + table: "AppIntents", + comment: + "Error shown by Siri or the Shortcuts app when the signed-in user lacks the capability to publish posts on the site." + ) + case .dateMustBeInFuture: + return LocalizedStringResource( + "ios-appintents.publishError.dateMustBeInFuture", + defaultValue: "The publish date must be in the future.", + table: "AppIntents", + comment: "Error shown by Siri or the Shortcuts app when the chosen schedule date is not 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 b48547e3c7de..b3fd4686da55 100644 --- a/Sources/Jetpack/AppIntents/JetpackAppShortcuts.swift +++ b/Sources/Jetpack/AppIntents/JetpackAppShortcuts.swift @@ -60,5 +60,33 @@ struct JetpackAppShortcuts: AppShortcutsProvider { ), 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", + table: "AppIntents", + comment: "Short title of the Publish Post shortcut tile in Spotlight and the Shortcuts app." + ), + 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", + table: "AppIntents", + comment: "Short title of the Schedule Post shortcut tile in Spotlight and the Shortcuts app." + ), + systemImageName: "calendar.badge.clock" + ) } } diff --git a/Sources/Jetpack/AppIntents/PublishPostIntent.swift b/Sources/Jetpack/AppIntents/PublishPostIntent.swift new file mode 100644 index 000000000000..28941e8da2b7 --- /dev/null +++ b/Sources/Jetpack/AppIntents/PublishPostIntent.swift @@ -0,0 +1,96 @@ +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", + table: "AppIntents", + comment: "Title of the App Intent that publishes a post. Shown in the Shortcuts app and Spotlight." + ) + static let description = IntentDescription( + LocalizedStringResource( + "ios-appintents.publishPost.description", + defaultValue: "Publishes one of your draft, pending, or scheduled posts.", + table: "AppIntents", + comment: "Description of the App Intent that publishes a post. Shown in the Shortcuts app." + ) + ) + + @Parameter( + title: LocalizedStringResource( + "ios-appintents.publishPost.postParameter", + defaultValue: "Post", + table: "AppIntents", + comment: "Label of the post parameter of the Publish Post App Intent. Shown in the Shortcuts app." + ), + 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)”?", + table: "AppIntents", + comment: + "Confirmation dialog shown by Siri or the Shortcuts app before publishing. %1$@ is the post 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)).", + table: "AppIntents", + comment: + "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." + ) + ) + } else { + dialog = IntentDialog( + LocalizedStringResource( + "ios-appintents.publishPost.publishedDialog", + defaultValue: "Published “\(title)”.", + table: "AppIntents", + comment: + "Dialog shown by Siri or the Shortcuts app after a post was published. %1$@ is the post 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..687bd8d6f5c4 --- /dev/null +++ b/Sources/Jetpack/AppIntents/SchedulePostIntent.swift @@ -0,0 +1,115 @@ +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", + table: "AppIntents", + comment: "Title of the App Intent that schedules a post. Shown in the Shortcuts app and Spotlight." + ) + 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.", + table: "AppIntents", + comment: "Description of the App Intent that schedules a post. Shown in the Shortcuts app." + ) + ) + + @Parameter( + title: LocalizedStringResource( + "ios-appintents.schedulePost.postParameter", + defaultValue: "Post", + table: "AppIntents", + comment: "Label of the post parameter of the Schedule Post App Intent. Shown in the Shortcuts app." + ), + optionsProvider: PublishablePostOptionsProvider() + ) + var post: PostEntity + + @Parameter( + title: LocalizedStringResource( + "publishDatePicker.title", + defaultValue: "Publish Date", + table: "Localizable", + comment: "Label of the date parameter of the Schedule Post App Intent. Shown in the Shortcuts app." + ) + ) + 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)?", + table: "AppIntents", + comment: + "Confirmation dialog shown by Siri or the Shortcuts app before scheduling. %1$@ is the post title, %2$@ the formatted publish date." + ) + ) + ), + 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)”.", + table: "AppIntents", + comment: + "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." + ) + ) + } else { + let scheduledDate = (target.dateCreated ?? date).formatted(date: .abbreviated, time: .shortened) + dialog = IntentDialog( + LocalizedStringResource( + "ios-appintents.schedulePost.scheduledDialog", + defaultValue: "Scheduled “\(title)” for \(scheduledDate).", + table: "AppIntents", + comment: + "Dialog shown by Siri or the Shortcuts app after a post was scheduled. %1$@ is the post title, %2$@ the formatted publish date." + ) + ) + } + return .result(value: updated, dialog: dialog) + } +} diff --git a/Sources/Jetpack/Resources/AppIntents.xcstrings b/Sources/Jetpack/Resources/AppIntents.xcstrings index 3f886fbb642b..ae9b37233a8c 100644 --- a/Sources/Jetpack/Resources/AppIntents.xcstrings +++ b/Sources/Jetpack/Resources/AppIntents.xcstrings @@ -188,6 +188,149 @@ } } }, + "ios-appintents.publishError.dateMustBeInFuture" : { + "comment" : "Error shown by Siri or the Shortcuts app when the chosen schedule date is not in the future.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The publish date must be in the future." + } + } + } + }, + "ios-appintents.publishError.hasUnsavedChanges" : { + "comment" : "Error shown by Siri or the Shortcuts app when the post has local changes that must be resolved in the app before publishing.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The post has unsaved changes. Open it in the app to publish it." + } + } + } + }, + "ios-appintents.publishError.isPage" : { + "comment" : "Error shown by Siri or the Shortcuts app when the selected item is a page, which publish App Intents do not support.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Publishing pages is not supported." + } + } + } + }, + "ios-appintents.publishError.notPublishable" : { + "comment" : "Error shown by Siri or the Shortcuts app when the post is not in a publishable state.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The post is already published or cannot be published." + } + } + } + }, + "ios-appintents.publishError.postNotFound" : { + "comment" : "Error shown by Siri or the Shortcuts app when the post a publish or schedule App Intent should act on no longer exists.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The post could not be found." + } + } + } + }, + "ios-appintents.publishError.publishingNotAllowed" : { + "comment" : "Error shown by Siri or the Shortcuts app when the signed-in user lacks the capability to publish posts on the site.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "You don't have permission to publish posts on this site." + } + } + } + }, + "ios-appintents.publishPost.confirmationDialog" : { + "comment" : "Confirmation dialog shown by Siri or the Shortcuts app before publishing. %1$@ is the post title.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Publish “%1$@”?" + } + } + } + }, + "ios-appintents.publishPost.description" : { + "comment" : "Description of the App Intent that publishes a post. Shown in the Shortcuts app.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Publishes one of your draft, pending, or scheduled posts." + } + } + } + }, + "ios-appintents.publishPost.postParameter" : { + "comment" : "Label of the post parameter of the Publish Post App Intent. Shown in the Shortcuts app.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Post" + } + } + } + }, + "ios-appintents.publishPost.publishedDialog" : { + "comment" : "Dialog shown by Siri or the Shortcuts app after a post was published. %1$@ is the post title.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Published “%1$@”." + } + } + } + }, + "ios-appintents.publishPost.scheduledDialog" : { + "comment" : "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.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Scheduled “%1$@” for %2$@." + } + } + } + }, + "ios-appintents.publishPost.shortTitle" : { + "comment" : "Short title of the Publish Post shortcut tile in Spotlight and the Shortcuts app.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Publish Post" + } + } + } + }, + "ios-appintents.publishPost.title" : { + "comment" : "Title of the App Intent that publishes a post. Shown in the Shortcuts app and Spotlight.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Publish Post" + } + } + } + }, "ios-appintents.readerPostEntity.placeholderTitle" : { "comment" : "Generic title shown in the Shortcuts app for a Reader post in a saved shortcut that is no longer cached on this device.", "localizations" : { @@ -209,6 +352,83 @@ } } } + }, + "ios-appintents.schedulePost.confirmationDialog" : { + "comment" : "Confirmation dialog shown by Siri or the Shortcuts app before scheduling. %1$@ is the post title, %2$@ the formatted publish date.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Schedule “%1$@” for %2$@?" + } + } + } + }, + "ios-appintents.schedulePost.description" : { + "comment" : "Description of the App Intent that schedules a post. Shown in the Shortcuts app.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Schedules one of your draft, pending, or scheduled posts to publish at a future date." + } + } + } + }, + "ios-appintents.schedulePost.postParameter" : { + "comment" : "Label of the post parameter of the Schedule Post App Intent. Shown in the Shortcuts app.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Post" + } + } + } + }, + "ios-appintents.schedulePost.publishedDialog" : { + "comment" : "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.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Published “%1$@”." + } + } + } + }, + "ios-appintents.schedulePost.scheduledDialog" : { + "comment" : "Dialog shown by Siri or the Shortcuts app after a post was scheduled. %1$@ is the post title, %2$@ the formatted publish date.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Scheduled “%1$@” for %2$@." + } + } + } + }, + "ios-appintents.schedulePost.shortTitle" : { + "comment" : "Short title of the Schedule Post shortcut tile in Spotlight and the Shortcuts app.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Schedule Post" + } + } + } + }, + "ios-appintents.schedulePost.title" : { + "comment" : "Title of the App Intent that schedules a post. Shown in the Shortcuts app and Spotlight.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Schedule Post" + } + } + } } }, "version" : "1.0" diff --git a/Sources/Jetpack/Resources/AppShortcuts.xcstrings b/Sources/Jetpack/Resources/AppShortcuts.xcstrings index 8557a78efe7b..e613619e74c5 100644 --- a/Sources/Jetpack/Resources/AppShortcuts.xcstrings +++ b/Sources/Jetpack/Resources/AppShortcuts.xcstrings @@ -61,6 +61,46 @@ } } }, + "Publish a draft in ${applicationName}" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Publish a draft in ${applicationName}" + } + } + } + }, + "Publish a post in ${applicationName}" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Publish a post in ${applicationName}" + } + } + } + }, + "Schedule a draft in ${applicationName}" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Schedule a draft in ${applicationName}" + } + } + } + }, + "Schedule a post in ${applicationName}" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Schedule a post in ${applicationName}" + } + } + } + }, "Show my ${applicationName} notifications" : { "localizations" : { "en" : { From de06546a9d0165a6c589832cf1dfcb001ff6196b 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 e2353082f6df..a9dca5bcc38b 100644 --- a/RELEASE-NOTES.txt +++ b/RELEASE-NOTES.txt @@ -14,6 +14,7 @@ * [*] Share Extension: Fix a crash after uploading a post to WordPress.com [#25773] * [*] 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