Skip to content

Commit 7586530

Browse files
committed
Add App Intent publishing helpers for posts
Adds eligibility, blocker, and update-parameter helpers that let App Intents publish or schedule server-synced posts. Posts with unsaved local edits, pages, local-only posts, and posts the user's role cannot publish are refused up front, using the same capability check and nil-capabilities allowance as the in-app publish affordances, rather than surfacing a raw server rejection after confirmation. Publishing a previously scheduled post moves its date to now through a RemotePostUpdateParameters helper shared with PostCoordinator.save, covering the private-publish case the earlier inline copies missed. The publishable status list is shared between the eligibility check and the fetch predicate.
1 parent e5e397d commit 7586530

3 files changed

Lines changed: 371 additions & 0 deletions

File tree

Lines changed: 266 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,266 @@
1+
import Foundation
2+
import Testing
3+
import WordPressKit
4+
5+
@testable import WordPress
6+
@testable import WordPressData
7+
8+
@MainActor
9+
@Suite("Post app intent publishing eligibility search")
10+
struct PostAppIntentPublishingSearchTests {
11+
@Test("returns drafts, pending, and scheduled posts but not published or trashed ones")
12+
func returnsOnlyPublishableStatuses() {
13+
let context = ContextManager.forTesting().mainContext
14+
let blog = BlogBuilder(context).with(dotComID: 111).build()
15+
let draft = PostBuilder(context, blog: blog).drafted().with(title: "Draft").build()
16+
draft.postID = 1
17+
let pending = PostBuilder(context, blog: blog).pending().with(title: "Pending").build()
18+
pending.postID = 2
19+
let scheduled = PostBuilder(context, blog: blog).scheduled().with(title: "Scheduled").build()
20+
scheduled.postID = 3
21+
let published = PostBuilder(context, blog: blog).published().with(title: "Published").build()
22+
published.postID = 4
23+
let trashed = PostBuilder(context, blog: blog).trashed().with(title: "Trashed").build()
24+
trashed.postID = 5
25+
26+
let results = Post.recentForAppIntentPublishing(in: context)
27+
28+
#expect(Set(results) == Set([draft, pending, scheduled]))
29+
}
30+
31+
@Test("excludes pages")
32+
func excludesPages() {
33+
let context = ContextManager.forTesting().mainContext
34+
let blog = BlogBuilder(context).with(dotComID: 111).build()
35+
let page = PageBuilder(context).build()
36+
page.blog = blog
37+
page.postTitle = "Draft page"
38+
page.status = .draft
39+
page.postID = 1
40+
41+
#expect(Post.recentForAppIntentPublishing(in: context) == [])
42+
}
43+
44+
@Test("excludes local-only posts")
45+
func excludesLocalOnlyPosts() {
46+
let context = ContextManager.forTesting().mainContext
47+
let blog = BlogBuilder(context).with(dotComID: 111).build()
48+
PostBuilder(context, blog: blog).drafted().with(title: "Local only").build()
49+
50+
#expect(Post.recentForAppIntentPublishing(in: context) == [])
51+
}
52+
53+
@Test("excludes posts with unsaved local edits")
54+
func excludesPostsWithUnsavedEdits() {
55+
let context = ContextManager.forTesting().mainContext
56+
let blog = BlogBuilder(context).with(dotComID: 111).build()
57+
let edited = PostBuilder(context, blog: blog).drafted().with(title: "Edited").build()
58+
edited.postID = 1
59+
_ = edited.createRevision()
60+
61+
#expect(Post.recentForAppIntentPublishing(in: context) == [])
62+
}
63+
64+
@Test("returns recent posts, most recently modified first")
65+
func returnsRecentPostsFirst() {
66+
let context = ContextManager.forTesting().mainContext
67+
let blog = BlogBuilder(context).with(dotComID: 111).build()
68+
let older = PostBuilder(context, blog: blog).drafted().with(title: "Older")
69+
.with(dateModified: Date(timeIntervalSince1970: 1000)).build()
70+
older.postID = 1
71+
let newer = PostBuilder(context, blog: blog).drafted().with(title: "Newer")
72+
.with(dateModified: Date(timeIntervalSince1970: 2000)).build()
73+
newer.postID = 2
74+
75+
#expect(Post.recentForAppIntentPublishing(in: context) == [newer, older])
76+
}
77+
78+
@Test("caps the number of results")
79+
func capsResults() {
80+
let context = ContextManager.forTesting().mainContext
81+
let blog = BlogBuilder(context).with(dotComID: 111).build()
82+
for index in 1...3 {
83+
let post = PostBuilder(context, blog: blog).drafted().with(title: "Post \(index)").build()
84+
post.postID = NSNumber(value: index)
85+
}
86+
87+
#expect(Post.recentForAppIntentPublishing(limit: 2, in: context).count == 2)
88+
}
89+
}
90+
91+
@MainActor
92+
@Suite("Post app intent publishing blocker")
93+
struct PostAppIntentPublishingBlockerTests {
94+
@Test("a server-synced draft, pending, or scheduled post has no blocker")
95+
func publishablePostsHaveNoBlocker() {
96+
let context = ContextManager.forTesting().mainContext
97+
let blog = BlogBuilder(context).with(dotComID: 111).build()
98+
let draft = PostBuilder(context, blog: blog).drafted().build()
99+
draft.postID = 1
100+
let pending = PostBuilder(context, blog: blog).pending().build()
101+
pending.postID = 2
102+
let scheduled = PostBuilder(context, blog: blog).scheduled().build()
103+
scheduled.postID = 3
104+
105+
#expect(draft.appIntentPublishingBlocker == nil)
106+
#expect(pending.appIntentPublishingBlocker == nil)
107+
#expect(scheduled.appIntentPublishingBlocker == nil)
108+
}
109+
110+
@Test("a page is blocked")
111+
func pageIsBlocked() {
112+
let context = ContextManager.forTesting().mainContext
113+
let blog = BlogBuilder(context).with(dotComID: 111).build()
114+
let page = PageBuilder(context).build()
115+
page.blog = blog
116+
page.status = .draft
117+
page.postID = 1
118+
119+
#expect(page.appIntentPublishingBlocker == .isPage)
120+
}
121+
122+
@Test("a local-only post is blocked")
123+
func localOnlyPostIsBlocked() {
124+
let context = ContextManager.forTesting().mainContext
125+
let blog = BlogBuilder(context).with(dotComID: 111).build()
126+
let post = PostBuilder(context, blog: blog).drafted().build()
127+
128+
#expect(post.appIntentPublishingBlocker == .localOnly)
129+
}
130+
131+
@Test("a post with unsaved local edits is blocked")
132+
func postWithUnsavedEditsIsBlocked() {
133+
let context = ContextManager.forTesting().mainContext
134+
let blog = BlogBuilder(context).with(dotComID: 111).build()
135+
let post = PostBuilder(context, blog: blog).drafted().build()
136+
post.postID = 1
137+
_ = post.createRevision()
138+
139+
#expect(post.appIntentPublishingBlocker == .hasUnsavedChanges)
140+
}
141+
142+
@Test("a user without the publish capability is blocked")
143+
func missingPublishCapabilityIsBlocked() {
144+
let context = ContextManager.forTesting().mainContext
145+
let blog = BlogBuilder(context).with(dotComID: 111).with(capabilities: [.editPosts]).build()
146+
let post = PostBuilder(context, blog: blog).drafted().build()
147+
post.postID = 1
148+
149+
#expect(post.appIntentPublishingBlocker == .publishingNotAllowed)
150+
}
151+
152+
@Test("a user with the publish capability has no blocker")
153+
func publishCapabilityHasNoBlocker() {
154+
let context = ContextManager.forTesting().mainContext
155+
let blog = BlogBuilder(context).with(dotComID: 111).with(capabilities: [.publishPosts]).build()
156+
let post = PostBuilder(context, blog: blog).drafted().build()
157+
post.postID = 1
158+
159+
#expect(post.appIntentPublishingBlocker == nil)
160+
}
161+
162+
@Test("a site without loaded capabilities stays allowed")
163+
func nilCapabilitiesHasNoBlocker() {
164+
let context = ContextManager.forTesting().mainContext
165+
let blog = BlogBuilder(context).with(dotComID: 111).build()
166+
let post = PostBuilder(context, blog: blog).drafted().build()
167+
post.postID = 1
168+
169+
#expect(blog.capabilities == nil)
170+
#expect(post.appIntentPublishingBlocker == nil)
171+
}
172+
173+
@Test("a published or trashed post is blocked")
174+
func nonPublishableStatusIsBlocked() {
175+
let context = ContextManager.forTesting().mainContext
176+
let blog = BlogBuilder(context).with(dotComID: 111).build()
177+
let published = PostBuilder(context, blog: blog).published().build()
178+
published.postID = 1
179+
let trashed = PostBuilder(context, blog: blog).trashed().build()
180+
trashed.postID = 2
181+
182+
#expect(published.appIntentPublishingBlocker == .notPublishable)
183+
#expect(trashed.appIntentPublishingBlocker == .notPublishable)
184+
}
185+
}
186+
187+
@MainActor
188+
@Suite("Post app intent publishing parameters")
189+
struct PostAppIntentPublishingParametersTests {
190+
@Test("publishing a draft sets the publish status and keeps the date")
191+
func publishParametersForDraft() {
192+
let context = ContextManager.forTesting().mainContext
193+
let blog = BlogBuilder(context).with(dotComID: 111).build()
194+
let draft = PostBuilder(context, blog: blog).drafted().build()
195+
draft.postID = 1
196+
197+
let changes = draft.appIntentPublishParameters(now: Date(timeIntervalSince1970: 5000))
198+
199+
#expect(changes.status == Post.Status.publish.rawValue)
200+
#expect(changes.date == nil)
201+
}
202+
203+
@Test("publishing a scheduled post moves its date to now so it publishes immediately")
204+
func publishParametersForScheduledPost() {
205+
let context = ContextManager.forTesting().mainContext
206+
let blog = BlogBuilder(context).with(dotComID: 111).build()
207+
let scheduled = PostBuilder(context, blog: blog).scheduled().build()
208+
scheduled.postID = 1
209+
let now = Date(timeIntervalSince1970: 5000)
210+
211+
let changes = scheduled.appIntentPublishParameters(now: now)
212+
213+
#expect(changes.status == Post.Status.publish.rawValue)
214+
#expect(changes.date == now)
215+
}
216+
217+
@Test("scheduling sets the scheduled status and the given date")
218+
func scheduleParameters() {
219+
let context = ContextManager.forTesting().mainContext
220+
let blog = BlogBuilder(context).with(dotComID: 111).build()
221+
let draft = PostBuilder(context, blog: blog).drafted().build()
222+
draft.postID = 1
223+
let date = Date(timeIntervalSince1970: 90000)
224+
225+
let changes = draft.appIntentScheduleParameters(for: date)
226+
227+
#expect(changes.status == Post.Status.scheduled.rawValue)
228+
#expect(changes.date == date)
229+
}
230+
}
231+
232+
@Suite("Remote post update parameters publish date coercion")
233+
struct RemotePostUpdateParametersPublishingTests {
234+
@Test("publishing privately from scheduled also moves the date to now")
235+
func privatePublishFromScheduledMovesDate() {
236+
var changes = RemotePostUpdateParameters()
237+
changes.status = Post.Status.publishPrivate.rawValue
238+
let now = Date(timeIntervalSince1970: 5000)
239+
240+
changes.setDateForImmediatePublishIfNeeded(previousStatus: .scheduled, now: now)
241+
242+
#expect(changes.date == now)
243+
}
244+
245+
@Test("an explicitly chosen publish date is preserved")
246+
func explicitDateIsPreserved() {
247+
var changes = RemotePostUpdateParameters()
248+
changes.status = Post.Status.publish.rawValue
249+
let date = Date(timeIntervalSince1970: 90000)
250+
changes.date = date
251+
252+
changes.setDateForImmediatePublishIfNeeded(previousStatus: .scheduled, now: Date(timeIntervalSince1970: 5000))
253+
254+
#expect(changes.date == date)
255+
}
256+
257+
@Test("a post that was not scheduled keeps a nil date")
258+
func nonScheduledPreviousStatusKeepsNilDate() {
259+
var changes = RemotePostUpdateParameters()
260+
changes.status = Post.Status.publish.rawValue
261+
262+
changes.setDateForImmediatePublishIfNeeded(previousStatus: .draft, now: Date(timeIntervalSince1970: 5000))
263+
264+
#expect(changes.date == nil)
265+
}
266+
}
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import Foundation
2+
import WordPressData
3+
import WordPressKit
4+
5+
extension AbstractPost {
6+
/// Why an App Intent cannot publish or schedule the post.
7+
enum AppIntentPublishingBlocker {
8+
case isPage
9+
case localOnly
10+
case hasUnsavedChanges
11+
case notPublishable
12+
case publishingNotAllowed
13+
}
14+
15+
/// The statuses an App Intent can publish or schedule a post from, shared
16+
/// between the eligibility check and the picker fetch so they cannot
17+
/// drift apart.
18+
static let appIntentPublishableStatuses: [Status] = [.draft, .pending, .scheduled]
19+
20+
/// Returns the reason an App Intent must refuse to publish or schedule
21+
/// this post, or `nil` when it can proceed.
22+
///
23+
/// Posts with unsaved local edits are refused rather than synced: an
24+
/// intent publishing half-finished editor changes is never what the
25+
/// user asked for.
26+
var appIntentPublishingBlocker: AppIntentPublishingBlocker? {
27+
if self is Page {
28+
return .isPage
29+
}
30+
guard hasRemote() else {
31+
return .localOnly
32+
}
33+
if hasRevision() {
34+
return .hasUnsavedChanges
35+
}
36+
guard let status, Self.appIntentPublishableStatuses.contains(status) else {
37+
return .notPublishable
38+
}
39+
// Sites without loaded capabilities (self-hosted) stay allowed, the
40+
// same trade-off the in-app publish affordances make.
41+
if blog.capabilities != nil && !blog.isPublishingPostsAllowed() {
42+
return .publishingNotAllowed
43+
}
44+
return nil
45+
}
46+
47+
/// The delta that publishes the post immediately.
48+
func appIntentPublishParameters(now: Date = .now) -> RemotePostUpdateParameters {
49+
var changes = RemotePostUpdateParameters()
50+
changes.status = Post.Status.publish.rawValue
51+
changes.setDateForImmediatePublishIfNeeded(previousStatus: status, now: now)
52+
return changes
53+
}
54+
55+
/// The delta that schedules the post for the given date.
56+
func appIntentScheduleParameters(for date: Date) -> RemotePostUpdateParameters {
57+
var changes = RemotePostUpdateParameters()
58+
changes.status = Post.Status.scheduled.rawValue
59+
changes.date = date
60+
return changes
61+
}
62+
}
63+
64+
extension Post {
65+
/// Returns the most recently modified posts an App Intent can offer for
66+
/// publishing or scheduling. Only server-synced originals in a
67+
/// publishable status with no unsaved local edits qualify; see
68+
/// `appIntentPublishingBlocker` for why the rest are excluded.
69+
///
70+
/// The result is capped: `DynamicOptionsProvider` has no search hook on
71+
/// iOS 17, so this feeds a fixed picker of recents rather than a
72+
/// searchable index.
73+
static func recentForAppIntentPublishing(
74+
limit: Int = 20,
75+
in context: NSManagedObjectContext
76+
) -> [Post] {
77+
let statuses = appIntentPublishableStatuses.map(\.rawValue)
78+
let request = NSFetchRequest<Post>(entityName: Post.entityName())
79+
request.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [
80+
NSPredicate(format: "original = NULL AND revision = NULL AND postID > 0"),
81+
NSPredicate(format: "status IN %@", statuses)
82+
])
83+
request.sortDescriptors = [NSSortDescriptor(key: #keyPath(AbstractPost.dateModified), ascending: false)]
84+
request.fetchLimit = limit
85+
return (try? context.fetch(request)) ?? []
86+
}
87+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import Foundation
2+
import WordPressData
3+
import WordPressKit
4+
5+
extension RemotePostUpdateParameters {
6+
/// If the post was previously scheduled and these changes publish it
7+
/// without specifying a new date, sets the date to `now`; otherwise the
8+
/// server would leave the post scheduled instead of publishing it.
9+
mutating func setDateForImmediatePublishIfNeeded(previousStatus: BasePost.Status?, now: Date = .now) {
10+
guard status == Post.Status.publish.rawValue || status == Post.Status.publishPrivate.rawValue,
11+
previousStatus == .scheduled,
12+
date == nil
13+
else {
14+
return
15+
}
16+
date = now
17+
}
18+
}

0 commit comments

Comments
 (0)