Skip to content

Commit ed02b79

Browse files
committed
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.
1 parent 0bcd978 commit ed02b79

8 files changed

Lines changed: 644 additions & 0 deletions
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: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
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+
table: "AppIntents",
26+
comment:
27+
"Error shown by Siri or the Shortcuts app when the post a publish or schedule App Intent should act on no longer exists."
28+
)
29+
case .isPage:
30+
return LocalizedStringResource(
31+
"ios-appintents.publishError.isPage",
32+
defaultValue: "Publishing pages is not supported.",
33+
table: "AppIntents",
34+
comment:
35+
"Error shown by Siri or the Shortcuts app when the selected item is a page, which publish App Intents do not support."
36+
)
37+
case .hasUnsavedChanges:
38+
return LocalizedStringResource(
39+
"ios-appintents.publishError.hasUnsavedChanges",
40+
defaultValue: "The post has unsaved changes. Open it in the app to publish it.",
41+
table: "AppIntents",
42+
comment:
43+
"Error shown by Siri or the Shortcuts app when the post has local changes that must be resolved in the app before publishing."
44+
)
45+
case .notPublishable:
46+
return LocalizedStringResource(
47+
"ios-appintents.publishError.notPublishable",
48+
defaultValue: "The post is already published or cannot be published.",
49+
table: "AppIntents",
50+
comment: "Error shown by Siri or the Shortcuts app when the post is not in a publishable state."
51+
)
52+
case .publishingNotAllowed:
53+
return LocalizedStringResource(
54+
"ios-appintents.publishError.publishingNotAllowed",
55+
defaultValue: "You don't have permission to publish posts on this site.",
56+
table: "AppIntents",
57+
comment:
58+
"Error shown by Siri or the Shortcuts app when the signed-in user lacks the capability to publish posts on the site."
59+
)
60+
case .dateMustBeInFuture:
61+
return LocalizedStringResource(
62+
"ios-appintents.publishError.dateMustBeInFuture",
63+
defaultValue: "The publish date must be in the future.",
64+
table: "AppIntents",
65+
comment: "Error shown by Siri or the Shortcuts app when the chosen schedule date is not in the future."
66+
)
67+
case .saveFailed(let reason):
68+
return "\(reason)"
69+
}
70+
}
71+
}
72+
73+
extension AppIntentPublishError {
74+
init(_ blocker: AbstractPost.AppIntentPublishingBlocker) {
75+
switch blocker {
76+
case .isPage:
77+
self = .isPage
78+
case .hasUnsavedChanges:
79+
self = .hasUnsavedChanges
80+
case .localOnly, .notPublishable:
81+
self = .notPublishable
82+
case .publishingNotAllowed:
83+
self = .publishingNotAllowed
84+
}
85+
}
86+
}

Sources/Jetpack/AppIntents/JetpackAppShortcuts.swift

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,5 +60,33 @@ struct JetpackAppShortcuts: AppShortcutsProvider {
6060
),
6161
systemImageName: "book"
6262
)
63+
AppShortcut(
64+
intent: PublishPostIntent(),
65+
phrases: [
66+
"Publish a post in \(.applicationName)",
67+
"Publish a draft in \(.applicationName)"
68+
],
69+
shortTitle: LocalizedStringResource(
70+
"ios-appintents.publishPost.shortTitle",
71+
defaultValue: "Publish Post",
72+
table: "AppIntents",
73+
comment: "Short title of the Publish Post shortcut tile in Spotlight and the Shortcuts app."
74+
),
75+
systemImageName: "paperplane"
76+
)
77+
AppShortcut(
78+
intent: SchedulePostIntent(),
79+
phrases: [
80+
"Schedule a post in \(.applicationName)",
81+
"Schedule a draft in \(.applicationName)"
82+
],
83+
shortTitle: LocalizedStringResource(
84+
"ios-appintents.schedulePost.shortTitle",
85+
defaultValue: "Schedule Post",
86+
table: "AppIntents",
87+
comment: "Short title of the Schedule Post shortcut tile in Spotlight and the Shortcuts app."
88+
),
89+
systemImageName: "calendar.badge.clock"
90+
)
6391
}
6492
}
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
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(
8+
"ios-appintents.publishPost.title",
9+
defaultValue: "Publish Post",
10+
table: "AppIntents",
11+
comment: "Title of the App Intent that publishes a post. Shown in the Shortcuts app and Spotlight."
12+
)
13+
static let description = IntentDescription(
14+
LocalizedStringResource(
15+
"ios-appintents.publishPost.description",
16+
defaultValue: "Publishes one of your draft, pending, or scheduled posts.",
17+
table: "AppIntents",
18+
comment: "Description of the App Intent that publishes a post. Shown in the Shortcuts app."
19+
)
20+
)
21+
22+
@Parameter(
23+
title: LocalizedStringResource(
24+
"ios-appintents.publishPost.postParameter",
25+
defaultValue: "Post",
26+
table: "AppIntents",
27+
comment: "Label of the post parameter of the Publish Post App Intent. Shown in the Shortcuts app."
28+
),
29+
optionsProvider: PublishablePostOptionsProvider()
30+
)
31+
var post: PostEntity
32+
33+
@MainActor
34+
func perform() async throws -> some IntentResult & ProvidesDialog & ReturnsValue<PostEntity> {
35+
let context = ContextManager.shared.mainContext
36+
guard let target = AbstractPost.forAppIntent(identifier: post.id, in: context) else {
37+
throw AppIntentPublishError.postNotFound
38+
}
39+
if let blocker = target.appIntentPublishingBlocker {
40+
throw AppIntentPublishError(blocker)
41+
}
42+
43+
let title = target.titleForDisplay()
44+
// TODO: migrate to requestConfirmation(conditions:actionName:dialog:) once the deployment target reaches iOS 18.
45+
try await requestConfirmation(
46+
result: .result(
47+
dialog: IntentDialog(
48+
LocalizedStringResource(
49+
"ios-appintents.publishPost.confirmationDialog",
50+
defaultValue: "Publish “\(title)”?",
51+
table: "AppIntents",
52+
comment:
53+
"Confirmation dialog shown by Siri or the Shortcuts app before publishing. %1$@ is the post title."
54+
)
55+
)
56+
),
57+
confirmationActionName: .post
58+
)
59+
// The post can change while the confirmation dialog is up (e.g. the
60+
// editor creates an unsaved revision); re-check before acting.
61+
if let blocker = target.appIntentPublishingBlocker {
62+
throw AppIntentPublishError(blocker)
63+
}
64+
65+
try await AppIntentPostSaving.save(target, changes: target.appIntentPublishParameters())
66+
67+
guard let updated = PostEntity(post: target) else {
68+
throw AppIntentPublishError.postNotFound
69+
}
70+
// A post with a future publish date comes back scheduled rather than
71+
// published; the dialog reflects what the server actually did.
72+
let dialog: IntentDialog
73+
if target.status == .scheduled, let date = target.dateCreated {
74+
dialog = IntentDialog(
75+
LocalizedStringResource(
76+
"ios-appintents.publishPost.scheduledDialog",
77+
defaultValue: "Scheduled “\(title)” for \(date.formatted(date: .abbreviated, time: .shortened)).",
78+
table: "AppIntents",
79+
comment:
80+
"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."
81+
)
82+
)
83+
} else {
84+
dialog = IntentDialog(
85+
LocalizedStringResource(
86+
"ios-appintents.publishPost.publishedDialog",
87+
defaultValue: "Published “\(title)”.",
88+
table: "AppIntents",
89+
comment:
90+
"Dialog shown by Siri or the Shortcuts app after a post was published. %1$@ is the post title."
91+
)
92+
)
93+
}
94+
return .result(value: updated, dialog: dialog)
95+
}
96+
}
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: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
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(
9+
"ios-appintents.schedulePost.title",
10+
defaultValue: "Schedule Post",
11+
table: "AppIntents",
12+
comment: "Title of the App Intent that schedules a post. Shown in the Shortcuts app and Spotlight."
13+
)
14+
static let description = IntentDescription(
15+
LocalizedStringResource(
16+
"ios-appintents.schedulePost.description",
17+
defaultValue: "Schedules one of your draft, pending, or scheduled posts to publish at a future date.",
18+
table: "AppIntents",
19+
comment: "Description of the App Intent that schedules a post. Shown in the Shortcuts app."
20+
)
21+
)
22+
23+
@Parameter(
24+
title: LocalizedStringResource(
25+
"ios-appintents.schedulePost.postParameter",
26+
defaultValue: "Post",
27+
table: "AppIntents",
28+
comment: "Label of the post parameter of the Schedule Post App Intent. Shown in the Shortcuts app."
29+
),
30+
optionsProvider: PublishablePostOptionsProvider()
31+
)
32+
var post: PostEntity
33+
34+
@Parameter(
35+
title: LocalizedStringResource(
36+
"publishDatePicker.title",
37+
defaultValue: "Publish Date",
38+
table: "Localizable",
39+
comment: "Label of the date parameter of the Schedule Post App Intent. Shown in the Shortcuts app."
40+
)
41+
)
42+
var date: Date
43+
44+
@MainActor
45+
func perform() async throws -> some IntentResult & ProvidesDialog & ReturnsValue<PostEntity> {
46+
let context = ContextManager.shared.mainContext
47+
guard let target = AbstractPost.forAppIntent(identifier: post.id, in: context) else {
48+
throw AppIntentPublishError.postNotFound
49+
}
50+
if let blocker = target.appIntentPublishingBlocker {
51+
throw AppIntentPublishError(blocker)
52+
}
53+
guard date > .now else {
54+
throw AppIntentPublishError.dateMustBeInFuture
55+
}
56+
57+
let title = target.titleForDisplay()
58+
let formattedDate = date.formatted(date: .abbreviated, time: .shortened)
59+
// TODO: migrate to requestConfirmation(conditions:actionName:dialog:) once the deployment target reaches iOS 18.
60+
try await requestConfirmation(
61+
result: .result(
62+
dialog: IntentDialog(
63+
LocalizedStringResource(
64+
"ios-appintents.schedulePost.confirmationDialog",
65+
defaultValue: "Schedule “\(title)” for \(formattedDate)?",
66+
table: "AppIntents",
67+
comment:
68+
"Confirmation dialog shown by Siri or the Shortcuts app before scheduling. %1$@ is the post title, %2$@ the formatted publish date."
69+
)
70+
)
71+
),
72+
confirmationActionName: .set
73+
)
74+
// The post can change and the chosen date can lapse while the
75+
// confirmation dialog is up; re-check both before acting.
76+
if let blocker = target.appIntentPublishingBlocker {
77+
throw AppIntentPublishError(blocker)
78+
}
79+
guard date > .now else {
80+
throw AppIntentPublishError.dateMustBeInFuture
81+
}
82+
83+
try await AppIntentPostSaving.save(target, changes: target.appIntentScheduleParameters(for: date))
84+
85+
guard let updated = PostEntity(post: target) else {
86+
throw AppIntentPublishError.postNotFound
87+
}
88+
// The server publishes immediately when the date is no longer far
89+
// enough in the future; the dialog reflects what it actually did.
90+
let dialog: IntentDialog
91+
if target.status == .publish {
92+
dialog = IntentDialog(
93+
LocalizedStringResource(
94+
"ios-appintents.schedulePost.publishedDialog",
95+
defaultValue: "Published “\(title)”.",
96+
table: "AppIntents",
97+
comment:
98+
"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."
99+
)
100+
)
101+
} else {
102+
let scheduledDate = (target.dateCreated ?? date).formatted(date: .abbreviated, time: .shortened)
103+
dialog = IntentDialog(
104+
LocalizedStringResource(
105+
"ios-appintents.schedulePost.scheduledDialog",
106+
defaultValue: "Scheduled “\(title)” for \(scheduledDate).",
107+
table: "AppIntents",
108+
comment:
109+
"Dialog shown by Siri or the Shortcuts app after a post was scheduled. %1$@ is the post title, %2$@ the formatted publish date."
110+
)
111+
)
112+
}
113+
return .result(value: updated, dialog: dialog)
114+
}
115+
}

0 commit comments

Comments
 (0)