Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions RELEASE-NOTES.txt
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
* [*] [internal] Stats widgets: migrate the site picker from SiriKit to App Intents [#25753]
* [*] Add App Shortcuts for creating a post and opening Notifications, Stats, and the Reader from Siri, Spotlight, and the Shortcuts app [#25754]
* [*] Posts and Reader posts can now be found and opened from Siri and the Shortcuts app [#25755]
* [*] Publish or schedule a post from Siri and the Shortcuts app, with a confirmation before anything goes live [#25756]


27.0
Expand Down
41 changes: 41 additions & 0 deletions Sources/Jetpack/AppIntents/AppIntentPostSaving.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
70 changes: 70 additions & 0 deletions Sources/Jetpack/AppIntents/AppIntentPublishError.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import AppIntents
import Foundation
import WordPressData

/// Errors surfaced to the system when a publish or schedule intent cannot act.
enum AppIntentPublishError: Error, CustomLocalizedStringResourceConvertible {
case postNotFound
case isPage
case hasUnsavedChanges
case notPublishable
case publishingNotAllowed
case dateMustBeInFuture

/// The save was rejected; `reason` carries the server's own message
/// (e.g. the `rest_cannot_publish` explanation) rather than a generic
/// failure string.
case saveFailed(reason: String)

var localizedStringResource: LocalizedStringResource {
switch self {
case .postNotFound:
return LocalizedStringResource(
"ios-appintents.publishError.postNotFound",
defaultValue: "The post could not be found."
)
case .isPage:
return LocalizedStringResource(
"ios-appintents.publishError.isPage",
defaultValue: "Publishing pages is not supported."
)
case .hasUnsavedChanges:
return LocalizedStringResource(
"ios-appintents.publishError.hasUnsavedChanges",
defaultValue: "The post has unsaved changes. Open it in the app to publish it."
)
case .notPublishable:
return LocalizedStringResource(
"ios-appintents.publishError.notPublishable",
defaultValue: "The post is already published or cannot be published."
)
case .publishingNotAllowed:
return LocalizedStringResource(
"ios-appintents.publishError.publishingNotAllowed",
defaultValue: "You don't have permission to publish posts on this site."
)
case .dateMustBeInFuture:
return LocalizedStringResource(
"ios-appintents.publishError.dateMustBeInFuture",
defaultValue: "The publish date must be in the future."
)
case .saveFailed(let reason):
return "\(reason)"
}
}
}

extension AppIntentPublishError {
init(_ blocker: AbstractPost.AppIntentPublishingBlocker) {
switch blocker {
case .isPage:
self = .isPage
case .hasUnsavedChanges:
self = .hasUnsavedChanges
case .localOnly, .notPublishable:
self = .notPublishable
case .publishingNotAllowed:
self = .publishingNotAllowed
}
}
}
21 changes: 21 additions & 0 deletions Sources/Jetpack/AppIntents/JetpackAppShortcuts.swift
Original file line number Diff line number Diff line change
Expand Up @@ -43,5 +43,26 @@ struct JetpackAppShortcuts: AppShortcutsProvider {
shortTitle: LocalizedStringResource("ios-appintents.openReader.shortTitle", defaultValue: "Reader"),
systemImageName: "book"
)
AppShortcut(
intent: PublishPostIntent(),
phrases: [
"Publish a post in \(.applicationName)",
"Publish a draft in \(.applicationName)"
],
shortTitle: LocalizedStringResource("ios-appintents.publishPost.shortTitle", defaultValue: "Publish Post"),
systemImageName: "paperplane"
)
AppShortcut(
intent: SchedulePostIntent(),
phrases: [
"Schedule a post in \(.applicationName)",
"Schedule a draft in \(.applicationName)"
],
shortTitle: LocalizedStringResource(
"ios-appintents.schedulePost.shortTitle",
defaultValue: "Schedule Post"
),
systemImageName: "calendar.badge.clock"
)
}
}
75 changes: 75 additions & 0 deletions Sources/Jetpack/AppIntents/PublishPostIntent.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import AppIntents
import Foundation
import WordPressData

/// Publishes a draft, pending, or scheduled post after the user confirms.
struct PublishPostIntent: AppIntent {
static let title = LocalizedStringResource("ios-appintents.publishPost.title", defaultValue: "Publish Post")
static let description = IntentDescription(
LocalizedStringResource(
"ios-appintents.publishPost.description",
defaultValue: "Publishes one of your draft, pending, or scheduled posts."
)
)

@Parameter(
title: LocalizedStringResource("ios-appintents.publishPost.postParameter", defaultValue: "Post"),
optionsProvider: PublishablePostOptionsProvider()
)
var post: PostEntity

@MainActor
func perform() async throws -> some IntentResult & ProvidesDialog & ReturnsValue<PostEntity> {
let context = ContextManager.shared.mainContext
guard let target = AbstractPost.forAppIntent(identifier: post.id, in: context) else {
throw AppIntentPublishError.postNotFound
}
if let blocker = target.appIntentPublishingBlocker {
throw AppIntentPublishError(blocker)
}

let title = target.titleForDisplay()
// TODO: migrate to requestConfirmation(conditions:actionName:dialog:) once the deployment target reaches iOS 18.
try await requestConfirmation(
result: .result(
dialog: IntentDialog(
LocalizedStringResource(
"ios-appintents.publishPost.confirmationDialog",
defaultValue: "Publish “\(title)”?"
)
)
),
confirmationActionName: .post
)
// The post can change while the confirmation dialog is up (e.g. the
// editor creates an unsaved revision); re-check before acting.
if let blocker = target.appIntentPublishingBlocker {
throw AppIntentPublishError(blocker)
}

try await AppIntentPostSaving.save(target, changes: target.appIntentPublishParameters())

guard let updated = PostEntity(post: target) else {
throw AppIntentPublishError.postNotFound
}
// A post with a future publish date comes back scheduled rather than
// published; the dialog reflects what the server actually did.
let dialog: IntentDialog
if target.status == .scheduled, let date = target.dateCreated {
dialog = IntentDialog(
LocalizedStringResource(
"ios-appintents.publishPost.scheduledDialog",
defaultValue: "Scheduled “\(title)” for \(date.formatted(date: .abbreviated, time: .shortened))."
)
)
} else {
dialog = IntentDialog(
LocalizedStringResource(
"ios-appintents.publishPost.publishedDialog",
defaultValue: "Published “\(title)”."
)
)
}
return .result(value: updated, dialog: dialog)
}
}
18 changes: 18 additions & 0 deletions Sources/Jetpack/AppIntents/PublishablePostOptionsProvider.swift
Original file line number Diff line number Diff line change
@@ -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) }
}
}
89 changes: 89 additions & 0 deletions Sources/Jetpack/AppIntents/SchedulePostIntent.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import AppIntents
import Foundation
import WordPressData

/// Schedules a draft, pending, or already-scheduled post for a future date
/// after the user confirms.
struct SchedulePostIntent: AppIntent {
static let title = LocalizedStringResource("ios-appintents.schedulePost.title", defaultValue: "Schedule Post")
static let description = IntentDescription(
LocalizedStringResource(
"ios-appintents.schedulePost.description",
defaultValue: "Schedules one of your draft, pending, or scheduled posts to publish at a future date."
)
)

@Parameter(
title: LocalizedStringResource("ios-appintents.schedulePost.postParameter", defaultValue: "Post"),
optionsProvider: PublishablePostOptionsProvider()
)
var post: PostEntity

@Parameter(
title: LocalizedStringResource("ios-appintents.schedulePost.dateParameter", defaultValue: "Publish Date")
)
var date: Date

@MainActor
func perform() async throws -> some IntentResult & ProvidesDialog & ReturnsValue<PostEntity> {
let context = ContextManager.shared.mainContext
guard let target = AbstractPost.forAppIntent(identifier: post.id, in: context) else {
throw AppIntentPublishError.postNotFound
}
if let blocker = target.appIntentPublishingBlocker {
throw AppIntentPublishError(blocker)
}
guard date > .now else {
throw AppIntentPublishError.dateMustBeInFuture
}

let title = target.titleForDisplay()
let formattedDate = date.formatted(date: .abbreviated, time: .shortened)
// TODO: migrate to requestConfirmation(conditions:actionName:dialog:) once the deployment target reaches iOS 18.
try await requestConfirmation(
result: .result(
dialog: IntentDialog(
LocalizedStringResource(
"ios-appintents.schedulePost.confirmationDialog",
defaultValue: "Schedule “\(title)” for \(formattedDate)?"
)
)
),
confirmationActionName: .set
)
// The post can change and the chosen date can lapse while the
// confirmation dialog is up; re-check both before acting.
if let blocker = target.appIntentPublishingBlocker {
throw AppIntentPublishError(blocker)
}
guard date > .now else {
throw AppIntentPublishError.dateMustBeInFuture
}

try await AppIntentPostSaving.save(target, changes: target.appIntentScheduleParameters(for: date))

guard let updated = PostEntity(post: target) else {
throw AppIntentPublishError.postNotFound
}
// The server publishes immediately when the date is no longer far
// enough in the future; the dialog reflects what it actually did.
let dialog: IntentDialog
if target.status == .publish {
dialog = IntentDialog(
LocalizedStringResource(
"ios-appintents.schedulePost.publishedDialog",
defaultValue: "Published “\(title)”."
)
)
} else {
let scheduledDate = (target.dateCreated ?? date).formatted(date: .abbreviated, time: .shortened)
dialog = IntentDialog(
LocalizedStringResource(
"ios-appintents.schedulePost.scheduledDialog",
defaultValue: "Scheduled “\(title)” for \(scheduledDate)."
)
)
}
return .result(value: updated, dialog: dialog)
}
}
12 changes: 12 additions & 0 deletions Sources/Jetpack/Resources/en.lproj/AppShortcuts.strings
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,15 @@

/* Siri phrase to open the Reader tab. ${applicationName} is the app's name. */
"Open Reader in ${applicationName}" = "Open Reader in ${applicationName}";

/* Siri phrase to publish a post. ${applicationName} is the app's name. */
"Publish a post in ${applicationName}" = "Publish a post in ${applicationName}";

/* Siri phrase to publish a post. ${applicationName} is the app's name. */
"Publish a draft in ${applicationName}" = "Publish a draft in ${applicationName}";

/* Siri phrase to schedule a post. ${applicationName} is the app's name. */
"Schedule a post in ${applicationName}" = "Schedule a post in ${applicationName}";

/* Siri phrase to schedule a post. ${applicationName} is the app's name. */
"Schedule a draft in ${applicationName}" = "Schedule a draft in ${applicationName}";
Loading