Skip to content

Commit a2c3e94

Browse files
committed
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.
1 parent e115a2e commit a2c3e94

8 files changed

Lines changed: 389 additions & 0 deletions

File tree

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import Foundation
2+
import WordPressData
3+
import WordPressKit
4+
5+
/// Saves a post status change on behalf of an App Intent.
6+
///
7+
/// Mirrors the `PostCoordinator.save` orchestration (sync pausing, error
8+
/// analytics, deleted-post cleanup, and the post-publish side effects)
9+
/// except for its UIKit pieces, which would present alerts and notices on
10+
/// top of the intent's own dialogs.
11+
enum AppIntentPostSaving {
12+
@MainActor
13+
static func save(_ post: AbstractPost, changes: RemotePostUpdateParameters) async throws {
14+
let coordinator = PostCoordinator.shared
15+
await coordinator.pauseSyncing(for: post)
16+
defer { coordinator.resumeSyncing(for: post) }
17+
18+
let previousStatus = post.status
19+
do {
20+
try await PostRepository().save(post, changes: changes)
21+
} catch {
22+
// Same operation string as PostCoordinator.save: the failure
23+
// surface is the same call, and analytics funnel on it.
24+
coordinator.trackError(error, operation: "post-save", post: post)
25+
// A post deleted on the server can never be saved again; drop
26+
// the local copy (as the in-app flow does) so the intents stop
27+
// offering it. The error message carries the post title.
28+
if let saveError = error as? PostRepository.PostSaveError, case .deleted = saveError {
29+
coordinator.handlePermanentlyDeleted(post)
30+
}
31+
// For WP.com endpoint errors the localized description is the
32+
// server's own message (e.g. why publishing was rejected).
33+
throw AppIntentPublishError.saveFailed(reason: error.localizedDescription)
34+
}
35+
coordinator.didPublish(post, previousStatus: previousStatus)
36+
// Unconditional on purpose: re-scheduling an already-scheduled post
37+
// does not pass didPublish's status-changed gate but must still
38+
// refresh the Spotlight entry; the repeated upsert is idempotent.
39+
SearchManager.shared.indexItem(post)
40+
}
41+
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import AppIntents
2+
import Foundation
3+
import WordPressData
4+
5+
/// Errors surfaced to the system when a publish or schedule intent cannot act.
6+
enum AppIntentPublishError: Error, CustomLocalizedStringResourceConvertible {
7+
case postNotFound
8+
case isPage
9+
case hasUnsavedChanges
10+
case notPublishable
11+
case publishingNotAllowed
12+
case dateMustBeInFuture
13+
14+
/// The save was rejected; `reason` carries the server's own message
15+
/// (e.g. the `rest_cannot_publish` explanation) rather than a generic
16+
/// failure string.
17+
case saveFailed(reason: String)
18+
19+
var localizedStringResource: LocalizedStringResource {
20+
switch self {
21+
case .postNotFound:
22+
return LocalizedStringResource(
23+
"ios-appintents.publishError.postNotFound",
24+
defaultValue: "The post could not be found."
25+
)
26+
case .isPage:
27+
return LocalizedStringResource(
28+
"ios-appintents.publishError.isPage",
29+
defaultValue: "Publishing pages is not supported."
30+
)
31+
case .hasUnsavedChanges:
32+
return LocalizedStringResource(
33+
"ios-appintents.publishError.hasUnsavedChanges",
34+
defaultValue: "The post has unsaved changes. Open it in the app to publish it."
35+
)
36+
case .notPublishable:
37+
return LocalizedStringResource(
38+
"ios-appintents.publishError.notPublishable",
39+
defaultValue: "The post is already published or cannot be published."
40+
)
41+
case .publishingNotAllowed:
42+
return LocalizedStringResource(
43+
"ios-appintents.publishError.publishingNotAllowed",
44+
defaultValue: "You don't have permission to publish posts on this site."
45+
)
46+
case .dateMustBeInFuture:
47+
return LocalizedStringResource(
48+
"ios-appintents.publishError.dateMustBeInFuture",
49+
defaultValue: "The publish date must be in the future."
50+
)
51+
case .saveFailed(let reason):
52+
return "\(reason)"
53+
}
54+
}
55+
}
56+
57+
extension AppIntentPublishError {
58+
init(_ blocker: AbstractPost.AppIntentPublishingBlocker) {
59+
switch blocker {
60+
case .isPage:
61+
self = .isPage
62+
case .hasUnsavedChanges:
63+
self = .hasUnsavedChanges
64+
case .localOnly, .notPublishable:
65+
self = .notPublishable
66+
case .publishingNotAllowed:
67+
self = .publishingNotAllowed
68+
}
69+
}
70+
}

Sources/Jetpack/AppIntents/JetpackAppShortcuts.swift

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,5 +43,26 @@ struct JetpackAppShortcuts: AppShortcutsProvider {
4343
shortTitle: LocalizedStringResource("ios-appintents.openReader.shortTitle", defaultValue: "Reader"),
4444
systemImageName: "book"
4545
)
46+
AppShortcut(
47+
intent: PublishPostIntent(),
48+
phrases: [
49+
"Publish a post in \(.applicationName)",
50+
"Publish a draft in \(.applicationName)"
51+
],
52+
shortTitle: LocalizedStringResource("ios-appintents.publishPost.shortTitle", defaultValue: "Publish Post"),
53+
systemImageName: "paperplane"
54+
)
55+
AppShortcut(
56+
intent: SchedulePostIntent(),
57+
phrases: [
58+
"Schedule a post in \(.applicationName)",
59+
"Schedule a draft in \(.applicationName)"
60+
],
61+
shortTitle: LocalizedStringResource(
62+
"ios-appintents.schedulePost.shortTitle",
63+
defaultValue: "Schedule Post"
64+
),
65+
systemImageName: "calendar.badge.clock"
66+
)
4667
}
4768
}
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import AppIntents
2+
import Foundation
3+
import WordPressData
4+
5+
/// Publishes a draft, pending, or scheduled post after the user confirms.
6+
struct PublishPostIntent: AppIntent {
7+
static let title = LocalizedStringResource("ios-appintents.publishPost.title", defaultValue: "Publish Post")
8+
static let description = IntentDescription(
9+
LocalizedStringResource(
10+
"ios-appintents.publishPost.description",
11+
defaultValue: "Publishes one of your draft, pending, or scheduled posts."
12+
)
13+
)
14+
15+
@Parameter(
16+
title: LocalizedStringResource("ios-appintents.publishPost.postParameter", defaultValue: "Post"),
17+
optionsProvider: PublishablePostOptionsProvider()
18+
)
19+
var post: PostEntity
20+
21+
@MainActor
22+
func perform() async throws -> some IntentResult & ProvidesDialog & ReturnsValue<PostEntity> {
23+
let context = ContextManager.shared.mainContext
24+
guard let target = AbstractPost.forAppIntent(identifier: post.id, in: context) else {
25+
throw AppIntentPublishError.postNotFound
26+
}
27+
if let blocker = target.appIntentPublishingBlocker {
28+
throw AppIntentPublishError(blocker)
29+
}
30+
31+
let title = target.titleForDisplay()
32+
// TODO: migrate to requestConfirmation(conditions:actionName:dialog:) once the deployment target reaches iOS 18.
33+
try await requestConfirmation(
34+
result: .result(
35+
dialog: IntentDialog(
36+
LocalizedStringResource(
37+
"ios-appintents.publishPost.confirmationDialog",
38+
defaultValue: "Publish “\(title)”?"
39+
)
40+
)
41+
),
42+
confirmationActionName: .post
43+
)
44+
// The post can change while the confirmation dialog is up (e.g. the
45+
// editor creates an unsaved revision); re-check before acting.
46+
if let blocker = target.appIntentPublishingBlocker {
47+
throw AppIntentPublishError(blocker)
48+
}
49+
50+
try await AppIntentPostSaving.save(target, changes: target.appIntentPublishParameters())
51+
52+
guard let updated = PostEntity(post: target) else {
53+
throw AppIntentPublishError.postNotFound
54+
}
55+
// A post with a future publish date comes back scheduled rather than
56+
// published; the dialog reflects what the server actually did.
57+
let dialog: IntentDialog
58+
if target.status == .scheduled, let date = target.dateCreated {
59+
dialog = IntentDialog(
60+
LocalizedStringResource(
61+
"ios-appintents.publishPost.scheduledDialog",
62+
defaultValue: "Scheduled “\(title)” for \(date.formatted(date: .abbreviated, time: .shortened))."
63+
)
64+
)
65+
} else {
66+
dialog = IntentDialog(
67+
LocalizedStringResource(
68+
"ios-appintents.publishPost.publishedDialog",
69+
defaultValue: "Published “\(title)”."
70+
)
71+
)
72+
}
73+
return .result(value: updated, dialog: dialog)
74+
}
75+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import AppIntents
2+
import Foundation
3+
import WordPressData
4+
5+
/// Offers only posts that can actually be published or scheduled, unlike the
6+
/// entity's default query which covers all posts and pages.
7+
///
8+
/// This provider only feeds the Shortcuts-editor picker. Free-text
9+
/// resolution (Siri voice, "Ask Each Time") still goes through the entity's
10+
/// default query, so an unpublishable pick is possible there and is rejected
11+
/// by the intents with a specific blocker error instead.
12+
struct PublishablePostOptionsProvider: DynamicOptionsProvider {
13+
@MainActor
14+
func results() async throws -> [PostEntity] {
15+
let context = ContextManager.shared.mainContext
16+
return Post.recentForAppIntentPublishing(in: context).compactMap { PostEntity(post: $0) }
17+
}
18+
}
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import AppIntents
2+
import Foundation
3+
import WordPressData
4+
5+
/// Schedules a draft, pending, or already-scheduled post for a future date
6+
/// after the user confirms.
7+
struct SchedulePostIntent: AppIntent {
8+
static let title = LocalizedStringResource("ios-appintents.schedulePost.title", defaultValue: "Schedule Post")
9+
static let description = IntentDescription(
10+
LocalizedStringResource(
11+
"ios-appintents.schedulePost.description",
12+
defaultValue: "Schedules one of your draft, pending, or scheduled posts to publish at a future date."
13+
)
14+
)
15+
16+
@Parameter(
17+
title: LocalizedStringResource("ios-appintents.schedulePost.postParameter", defaultValue: "Post"),
18+
optionsProvider: PublishablePostOptionsProvider()
19+
)
20+
var post: PostEntity
21+
22+
@Parameter(
23+
title: LocalizedStringResource("ios-appintents.schedulePost.dateParameter", defaultValue: "Publish Date")
24+
)
25+
var date: Date
26+
27+
@MainActor
28+
func perform() async throws -> some IntentResult & ProvidesDialog & ReturnsValue<PostEntity> {
29+
let context = ContextManager.shared.mainContext
30+
guard let target = AbstractPost.forAppIntent(identifier: post.id, in: context) else {
31+
throw AppIntentPublishError.postNotFound
32+
}
33+
if let blocker = target.appIntentPublishingBlocker {
34+
throw AppIntentPublishError(blocker)
35+
}
36+
guard date > .now else {
37+
throw AppIntentPublishError.dateMustBeInFuture
38+
}
39+
40+
let title = target.titleForDisplay()
41+
let formattedDate = date.formatted(date: .abbreviated, time: .shortened)
42+
// TODO: migrate to requestConfirmation(conditions:actionName:dialog:) once the deployment target reaches iOS 18.
43+
try await requestConfirmation(
44+
result: .result(
45+
dialog: IntentDialog(
46+
LocalizedStringResource(
47+
"ios-appintents.schedulePost.confirmationDialog",
48+
defaultValue: "Schedule “\(title)” for \(formattedDate)?"
49+
)
50+
)
51+
),
52+
confirmationActionName: .set
53+
)
54+
// The post can change and the chosen date can lapse while the
55+
// confirmation dialog is up; re-check both before acting.
56+
if let blocker = target.appIntentPublishingBlocker {
57+
throw AppIntentPublishError(blocker)
58+
}
59+
guard date > .now else {
60+
throw AppIntentPublishError.dateMustBeInFuture
61+
}
62+
63+
try await AppIntentPostSaving.save(target, changes: target.appIntentScheduleParameters(for: date))
64+
65+
guard let updated = PostEntity(post: target) else {
66+
throw AppIntentPublishError.postNotFound
67+
}
68+
// The server publishes immediately when the date is no longer far
69+
// enough in the future; the dialog reflects what it actually did.
70+
let dialog: IntentDialog
71+
if target.status == .publish {
72+
dialog = IntentDialog(
73+
LocalizedStringResource(
74+
"ios-appintents.schedulePost.publishedDialog",
75+
defaultValue: "Published “\(title)”."
76+
)
77+
)
78+
} else {
79+
let scheduledDate = (target.dateCreated ?? date).formatted(date: .abbreviated, time: .shortened)
80+
dialog = IntentDialog(
81+
LocalizedStringResource(
82+
"ios-appintents.schedulePost.scheduledDialog",
83+
defaultValue: "Scheduled “\(title)” for \(scheduledDate)."
84+
)
85+
)
86+
}
87+
return .result(value: updated, dialog: dialog)
88+
}
89+
}

Sources/Jetpack/Resources/en.lproj/AppShortcuts.strings

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,3 +37,15 @@
3737

3838
/* Siri phrase to open the Reader tab. ${applicationName} is the app's name. */
3939
"Open Reader in ${applicationName}" = "Open Reader in ${applicationName}";
40+
41+
/* Siri phrase to publish a post. ${applicationName} is the app's name. */
42+
"Publish a post in ${applicationName}" = "Publish a post in ${applicationName}";
43+
44+
/* Siri phrase to publish a post. ${applicationName} is the app's name. */
45+
"Publish a draft in ${applicationName}" = "Publish a draft in ${applicationName}";
46+
47+
/* Siri phrase to schedule a post. ${applicationName} is the app's name. */
48+
"Schedule a post in ${applicationName}" = "Schedule a post in ${applicationName}";
49+
50+
/* Siri phrase to schedule a post. ${applicationName} is the app's name. */
51+
"Schedule a draft in ${applicationName}" = "Schedule a draft in ${applicationName}";

0 commit comments

Comments
 (0)