diff --git a/RELEASE-NOTES.txt b/RELEASE-NOTES.txt index c0e70981ad57..123cf2afc20d 100644 --- a/RELEASE-NOTES.txt +++ b/RELEASE-NOTES.txt @@ -5,6 +5,7 @@ * [*] [internal] Stats widgets: migrate the site picker from SiriKit to App Intents [#25753] * [*] Add App Shortcuts for creating a post and opening Notifications, Stats, and the Reader from Siri, Spotlight, and the Shortcuts app [#25754] * [*] Posts and Reader posts can now be found and opened from Siri and the Shortcuts app [#25755] +* [*] Publish or schedule a post from Siri and the Shortcuts app, with a confirmation before anything goes live [#25756] 27.0 diff --git a/Sources/Jetpack/AppIntents/AppIntentPostSaving.swift b/Sources/Jetpack/AppIntents/AppIntentPostSaving.swift new file mode 100644 index 000000000000..2400293c1fcb --- /dev/null +++ b/Sources/Jetpack/AppIntents/AppIntentPostSaving.swift @@ -0,0 +1,41 @@ +import Foundation +import WordPressData +import WordPressKit + +/// Saves a post status change on behalf of an App Intent. +/// +/// Mirrors the `PostCoordinator.save` orchestration (sync pausing, error +/// analytics, deleted-post cleanup, and the post-publish side effects) +/// except for its UIKit pieces, which would present alerts and notices on +/// top of the intent's own dialogs. +enum AppIntentPostSaving { + @MainActor + static func save(_ post: AbstractPost, changes: RemotePostUpdateParameters) async throws { + let coordinator = PostCoordinator.shared + await coordinator.pauseSyncing(for: post) + defer { coordinator.resumeSyncing(for: post) } + + let previousStatus = post.status + do { + try await PostRepository().save(post, changes: changes) + } catch { + // Same operation string as PostCoordinator.save: the failure + // surface is the same call, and analytics funnel on it. + coordinator.trackError(error, operation: "post-save", post: post) + // A post deleted on the server can never be saved again; drop + // the local copy (as the in-app flow does) so the intents stop + // offering it. The error message carries the post title. + if let saveError = error as? PostRepository.PostSaveError, case .deleted = saveError { + coordinator.handlePermanentlyDeleted(post) + } + // For WP.com endpoint errors the localized description is the + // server's own message (e.g. why publishing was rejected). + throw AppIntentPublishError.saveFailed(reason: error.localizedDescription) + } + coordinator.didPublish(post, previousStatus: previousStatus) + // Unconditional on purpose: re-scheduling an already-scheduled post + // does not pass didPublish's status-changed gate but must still + // refresh the Spotlight entry; the repeated upsert is idempotent. + SearchManager.shared.indexItem(post) + } +} diff --git a/Sources/Jetpack/AppIntents/AppIntentPublishError.swift b/Sources/Jetpack/AppIntents/AppIntentPublishError.swift new file mode 100644 index 000000000000..dc7184428cab --- /dev/null +++ b/Sources/Jetpack/AppIntents/AppIntentPublishError.swift @@ -0,0 +1,70 @@ +import AppIntents +import Foundation +import WordPressData + +/// Errors surfaced to the system when a publish or schedule intent cannot act. +enum AppIntentPublishError: Error, CustomLocalizedStringResourceConvertible { + case postNotFound + case isPage + case hasUnsavedChanges + case notPublishable + case publishingNotAllowed + case dateMustBeInFuture + + /// The save was rejected; `reason` carries the server's own message + /// (e.g. the `rest_cannot_publish` explanation) rather than a generic + /// failure string. + case saveFailed(reason: String) + + var localizedStringResource: LocalizedStringResource { + switch self { + case .postNotFound: + return LocalizedStringResource( + "ios-appintents.publishError.postNotFound", + defaultValue: "The post could not be found." + ) + case .isPage: + return LocalizedStringResource( + "ios-appintents.publishError.isPage", + defaultValue: "Publishing pages is not supported." + ) + case .hasUnsavedChanges: + return LocalizedStringResource( + "ios-appintents.publishError.hasUnsavedChanges", + defaultValue: "The post has unsaved changes. Open it in the app to publish it." + ) + case .notPublishable: + return LocalizedStringResource( + "ios-appintents.publishError.notPublishable", + defaultValue: "The post is already published or cannot be published." + ) + case .publishingNotAllowed: + return LocalizedStringResource( + "ios-appintents.publishError.publishingNotAllowed", + defaultValue: "You don't have permission to publish posts on this site." + ) + case .dateMustBeInFuture: + return LocalizedStringResource( + "ios-appintents.publishError.dateMustBeInFuture", + defaultValue: "The publish date must be in the future." + ) + case .saveFailed(let reason): + return "\(reason)" + } + } +} + +extension AppIntentPublishError { + init(_ blocker: AbstractPost.AppIntentPublishingBlocker) { + switch blocker { + case .isPage: + self = .isPage + case .hasUnsavedChanges: + self = .hasUnsavedChanges + case .localOnly, .notPublishable: + self = .notPublishable + case .publishingNotAllowed: + self = .publishingNotAllowed + } + } +} diff --git a/Sources/Jetpack/AppIntents/JetpackAppShortcuts.swift b/Sources/Jetpack/AppIntents/JetpackAppShortcuts.swift index f95107888648..e07c70e8163e 100644 --- a/Sources/Jetpack/AppIntents/JetpackAppShortcuts.swift +++ b/Sources/Jetpack/AppIntents/JetpackAppShortcuts.swift @@ -43,5 +43,26 @@ struct JetpackAppShortcuts: AppShortcutsProvider { shortTitle: LocalizedStringResource("ios-appintents.openReader.shortTitle", defaultValue: "Reader"), systemImageName: "book" ) + AppShortcut( + intent: PublishPostIntent(), + phrases: [ + "Publish a post in \(.applicationName)", + "Publish a draft in \(.applicationName)" + ], + shortTitle: LocalizedStringResource("ios-appintents.publishPost.shortTitle", defaultValue: "Publish Post"), + systemImageName: "paperplane" + ) + AppShortcut( + intent: SchedulePostIntent(), + phrases: [ + "Schedule a post in \(.applicationName)", + "Schedule a draft in \(.applicationName)" + ], + shortTitle: LocalizedStringResource( + "ios-appintents.schedulePost.shortTitle", + defaultValue: "Schedule Post" + ), + systemImageName: "calendar.badge.clock" + ) } } diff --git a/Sources/Jetpack/AppIntents/PublishPostIntent.swift b/Sources/Jetpack/AppIntents/PublishPostIntent.swift new file mode 100644 index 000000000000..653dbe76cf7b --- /dev/null +++ b/Sources/Jetpack/AppIntents/PublishPostIntent.swift @@ -0,0 +1,75 @@ +import AppIntents +import Foundation +import WordPressData + +/// Publishes a draft, pending, or scheduled post after the user confirms. +struct PublishPostIntent: AppIntent { + static let title = LocalizedStringResource("ios-appintents.publishPost.title", defaultValue: "Publish Post") + static let description = IntentDescription( + LocalizedStringResource( + "ios-appintents.publishPost.description", + defaultValue: "Publishes one of your draft, pending, or scheduled posts." + ) + ) + + @Parameter( + title: LocalizedStringResource("ios-appintents.publishPost.postParameter", defaultValue: "Post"), + optionsProvider: PublishablePostOptionsProvider() + ) + var post: PostEntity + + @MainActor + func perform() async throws -> some IntentResult & ProvidesDialog & ReturnsValue { + let context = ContextManager.shared.mainContext + guard let target = AbstractPost.forAppIntent(identifier: post.id, in: context) else { + throw AppIntentPublishError.postNotFound + } + if let blocker = target.appIntentPublishingBlocker { + throw AppIntentPublishError(blocker) + } + + let title = target.titleForDisplay() + // TODO: migrate to requestConfirmation(conditions:actionName:dialog:) once the deployment target reaches iOS 18. + try await requestConfirmation( + result: .result( + dialog: IntentDialog( + LocalizedStringResource( + "ios-appintents.publishPost.confirmationDialog", + defaultValue: "Publish “\(title)”?" + ) + ) + ), + confirmationActionName: .post + ) + // The post can change while the confirmation dialog is up (e.g. the + // editor creates an unsaved revision); re-check before acting. + if let blocker = target.appIntentPublishingBlocker { + throw AppIntentPublishError(blocker) + } + + try await AppIntentPostSaving.save(target, changes: target.appIntentPublishParameters()) + + guard let updated = PostEntity(post: target) else { + throw AppIntentPublishError.postNotFound + } + // A post with a future publish date comes back scheduled rather than + // published; the dialog reflects what the server actually did. + let dialog: IntentDialog + if target.status == .scheduled, let date = target.dateCreated { + dialog = IntentDialog( + LocalizedStringResource( + "ios-appintents.publishPost.scheduledDialog", + defaultValue: "Scheduled “\(title)” for \(date.formatted(date: .abbreviated, time: .shortened))." + ) + ) + } else { + dialog = IntentDialog( + LocalizedStringResource( + "ios-appintents.publishPost.publishedDialog", + defaultValue: "Published “\(title)”." + ) + ) + } + return .result(value: updated, dialog: dialog) + } +} diff --git a/Sources/Jetpack/AppIntents/PublishablePostOptionsProvider.swift b/Sources/Jetpack/AppIntents/PublishablePostOptionsProvider.swift new file mode 100644 index 000000000000..14c5b42b4b1a --- /dev/null +++ b/Sources/Jetpack/AppIntents/PublishablePostOptionsProvider.swift @@ -0,0 +1,18 @@ +import AppIntents +import Foundation +import WordPressData + +/// Offers only posts that can actually be published or scheduled, unlike the +/// entity's default query which covers all posts and pages. +/// +/// This provider only feeds the Shortcuts-editor picker. Free-text +/// resolution (Siri voice, "Ask Each Time") still goes through the entity's +/// default query, so an unpublishable pick is possible there and is rejected +/// by the intents with a specific blocker error instead. +struct PublishablePostOptionsProvider: DynamicOptionsProvider { + @MainActor + func results() async throws -> [PostEntity] { + let context = ContextManager.shared.mainContext + return Post.recentForAppIntentPublishing(in: context).compactMap { PostEntity(post: $0) } + } +} diff --git a/Sources/Jetpack/AppIntents/SchedulePostIntent.swift b/Sources/Jetpack/AppIntents/SchedulePostIntent.swift new file mode 100644 index 000000000000..0e152a370413 --- /dev/null +++ b/Sources/Jetpack/AppIntents/SchedulePostIntent.swift @@ -0,0 +1,89 @@ +import AppIntents +import Foundation +import WordPressData + +/// Schedules a draft, pending, or already-scheduled post for a future date +/// after the user confirms. +struct SchedulePostIntent: AppIntent { + static let title = LocalizedStringResource("ios-appintents.schedulePost.title", defaultValue: "Schedule Post") + static let description = IntentDescription( + LocalizedStringResource( + "ios-appintents.schedulePost.description", + defaultValue: "Schedules one of your draft, pending, or scheduled posts to publish at a future date." + ) + ) + + @Parameter( + title: LocalizedStringResource("ios-appintents.schedulePost.postParameter", defaultValue: "Post"), + optionsProvider: PublishablePostOptionsProvider() + ) + var post: PostEntity + + @Parameter( + title: LocalizedStringResource("ios-appintents.schedulePost.dateParameter", defaultValue: "Publish Date") + ) + var date: Date + + @MainActor + func perform() async throws -> some IntentResult & ProvidesDialog & ReturnsValue { + let context = ContextManager.shared.mainContext + guard let target = AbstractPost.forAppIntent(identifier: post.id, in: context) else { + throw AppIntentPublishError.postNotFound + } + if let blocker = target.appIntentPublishingBlocker { + throw AppIntentPublishError(blocker) + } + guard date > .now else { + throw AppIntentPublishError.dateMustBeInFuture + } + + let title = target.titleForDisplay() + let formattedDate = date.formatted(date: .abbreviated, time: .shortened) + // TODO: migrate to requestConfirmation(conditions:actionName:dialog:) once the deployment target reaches iOS 18. + try await requestConfirmation( + result: .result( + dialog: IntentDialog( + LocalizedStringResource( + "ios-appintents.schedulePost.confirmationDialog", + defaultValue: "Schedule “\(title)” for \(formattedDate)?" + ) + ) + ), + confirmationActionName: .set + ) + // The post can change and the chosen date can lapse while the + // confirmation dialog is up; re-check both before acting. + if let blocker = target.appIntentPublishingBlocker { + throw AppIntentPublishError(blocker) + } + guard date > .now else { + throw AppIntentPublishError.dateMustBeInFuture + } + + try await AppIntentPostSaving.save(target, changes: target.appIntentScheduleParameters(for: date)) + + guard let updated = PostEntity(post: target) else { + throw AppIntentPublishError.postNotFound + } + // The server publishes immediately when the date is no longer far + // enough in the future; the dialog reflects what it actually did. + let dialog: IntentDialog + if target.status == .publish { + dialog = IntentDialog( + LocalizedStringResource( + "ios-appintents.schedulePost.publishedDialog", + defaultValue: "Published “\(title)”." + ) + ) + } else { + let scheduledDate = (target.dateCreated ?? date).formatted(date: .abbreviated, time: .shortened) + dialog = IntentDialog( + LocalizedStringResource( + "ios-appintents.schedulePost.scheduledDialog", + defaultValue: "Scheduled “\(title)” for \(scheduledDate)." + ) + ) + } + return .result(value: updated, dialog: dialog) + } +} diff --git a/Sources/Jetpack/Resources/en.lproj/AppShortcuts.strings b/Sources/Jetpack/Resources/en.lproj/AppShortcuts.strings index 34d8ecdd9937..226beb1dc13d 100644 --- a/Sources/Jetpack/Resources/en.lproj/AppShortcuts.strings +++ b/Sources/Jetpack/Resources/en.lproj/AppShortcuts.strings @@ -37,3 +37,15 @@ /* Siri phrase to open the Reader tab. ${applicationName} is the app's name. */ "Open Reader in ${applicationName}" = "Open Reader in ${applicationName}"; + +/* Siri phrase to publish a post. ${applicationName} is the app's name. */ +"Publish a post in ${applicationName}" = "Publish a post in ${applicationName}"; + +/* Siri phrase to publish a post. ${applicationName} is the app's name. */ +"Publish a draft in ${applicationName}" = "Publish a draft in ${applicationName}"; + +/* Siri phrase to schedule a post. ${applicationName} is the app's name. */ +"Schedule a post in ${applicationName}" = "Schedule a post in ${applicationName}"; + +/* Siri phrase to schedule a post. ${applicationName} is the app's name. */ +"Schedule a draft in ${applicationName}" = "Schedule a draft in ${applicationName}"; diff --git a/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 + } +} diff --git a/WordPress/Classes/Services/PostCoordinator.swift b/WordPress/Classes/Services/PostCoordinator.swift index b464bec59047..01bb1826fd76 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. @@ -89,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 { @@ -111,30 +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 { @@ -165,7 +161,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): @@ -183,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() { @@ -205,7 +205,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) } @@ -215,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) @@ -297,7 +305,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 +340,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 +424,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 +458,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 +556,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 +578,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 +600,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 +623,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 +669,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 +701,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 +739,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 +760,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 +825,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 diff --git a/WordPress/JetpackAppIntents/en.lproj/Localizable.strings b/WordPress/JetpackAppIntents/en.lproj/Localizable.strings index cc8dcb97c82e..622c0c240940 100644 --- a/WordPress/JetpackAppIntents/en.lproj/Localizable.strings +++ b/WordPress/JetpackAppIntents/en.lproj/Localizable.strings @@ -87,3 +87,66 @@ /* Error shown by Siri or the Shortcuts app when the post an App Intent should act on no longer exists. */ "openError.postNotFound" = "The post could not be found."; + +/* Title of the App Intent that publishes a post. Shown in the Shortcuts app and Spotlight. */ +"publishPost.title" = "Publish Post"; + +/* Description of the App Intent that publishes a post. Shown in the Shortcuts app. */ +"publishPost.description" = "Publishes one of your draft, pending, or scheduled posts."; + +/* Label of the post parameter of the Publish Post App Intent. Shown in the Shortcuts app. */ +"publishPost.postParameter" = "Post"; + +/* Short title of the Publish Post shortcut tile in Spotlight and the Shortcuts app. */ +"publishPost.shortTitle" = "Publish Post"; + +/* Confirmation dialog shown by Siri or the Shortcuts app before publishing. %1$@ is the post title. */ +"publishPost.confirmationDialog" = "Publish “%1$@”?"; + +/* Dialog shown by Siri or the Shortcuts app after a post was published. %1$@ is the post title. */ +"publishPost.publishedDialog" = "Published “%1$@”."; + +/* Dialog shown by Siri or the Shortcuts app when publishing a post scheduled it instead because its publish date is in the future. %1$@ is the post title, %2$@ the formatted publish date. */ +"publishPost.scheduledDialog" = "Scheduled “%1$@” for %2$@."; + +/* Title of the App Intent that schedules a post. Shown in the Shortcuts app and Spotlight. */ +"schedulePost.title" = "Schedule Post"; + +/* Description of the App Intent that schedules a post. Shown in the Shortcuts app. */ +"schedulePost.description" = "Schedules one of your draft, pending, or scheduled posts to publish at a future date."; + +/* Label of the post parameter of the Schedule Post App Intent. Shown in the Shortcuts app. */ +"schedulePost.postParameter" = "Post"; + +/* Label of the date parameter of the Schedule Post App Intent. Shown in the Shortcuts app. */ +"schedulePost.dateParameter" = "Publish Date"; + +/* Short title of the Schedule Post shortcut tile in Spotlight and the Shortcuts app. */ +"schedulePost.shortTitle" = "Schedule Post"; + +/* Confirmation dialog shown by Siri or the Shortcuts app before scheduling. %1$@ is the post title, %2$@ the formatted publish date. */ +"schedulePost.confirmationDialog" = "Schedule “%1$@” for %2$@?"; + +/* Dialog shown by Siri or the Shortcuts app after a post was scheduled. %1$@ is the post title, %2$@ the formatted publish date. */ +"schedulePost.scheduledDialog" = "Scheduled “%1$@” for %2$@."; + +/* Dialog shown by Siri or the Shortcuts app when scheduling published the post immediately because its date was no longer in the future. %1$@ is the post title. */ +"schedulePost.publishedDialog" = "Published “%1$@”."; + +/* Error shown by Siri or the Shortcuts app when the post a publish or schedule App Intent should act on no longer exists. */ +"publishError.postNotFound" = "The post could not be found."; + +/* Error shown by Siri or the Shortcuts app when the selected item is a page, which publish App Intents do not support. */ +"publishError.isPage" = "Publishing pages is not supported."; + +/* Error shown by Siri or the Shortcuts app when the post has local changes that must be resolved in the app before publishing. */ +"publishError.hasUnsavedChanges" = "The post has unsaved changes. Open it in the app to publish it."; + +/* Error shown by Siri or the Shortcuts app when the post is not in a publishable state. */ +"publishError.notPublishable" = "The post is already published or cannot be published."; + +/* Error shown by Siri or the Shortcuts app when the signed-in user lacks the capability to publish posts on the site. */ +"publishError.publishingNotAllowed" = "You don't have permission to publish posts on this site."; + +/* Error shown by Siri or the Shortcuts app when the chosen schedule date is not in the future. */ +"publishError.dateMustBeInFuture" = "The publish date must be in the future.";