Skip to content

Commit 41de0a2

Browse files
committed
Add App Intent identifier resolvers and search helpers for posts and reader posts
Entity identifiers reuse the Spotlight composite identifier format so a Spotlight item and its associated app entity always name the same post, including the xmlrpc branch for self-hosted sites; parsing lives in shared AppIntentIdentifier types so callers can validate identifiers without a Core Data lookup. Unresolvable or trashed posts resolve to nothing instead of falling back to another post, and reader post IDs survive values beyond Int32. The title search helpers back the entity queries: an empty query returns the most recent items, deduplicated for reader posts, and only posts that round-trip through the identifier resolvers are returned.
1 parent c15f48c commit 41de0a2

4 files changed

Lines changed: 482 additions & 2 deletions

File tree

Modules/Sources/WordPressData/Swift/SearchIdentifierGenerator.swift

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,11 @@ import Foundation
33
public struct SearchIdentifierGenerator {
44
internal static let separator = "|~~~|"
55

6-
internal static func composeUniqueIdentifier(itemType: SearchItemType, domain: String, identifier: String) -> String
7-
{
6+
internal static func composeUniqueIdentifier(
7+
itemType: SearchItemType,
8+
domain: String,
9+
identifier: String
10+
) -> String {
811
"\(itemType.stringValue())\(separator)\(domain)\(separator)\(identifier)"
912
}
1013

@@ -15,4 +18,18 @@ public struct SearchIdentifierGenerator {
1518

1619
return (SearchItemType(index: components[0]), components[1], components[2])
1720
}
21+
22+
/// A failable variant of `decomposeFromUniqueIdentifier(_:)` for identifiers
23+
/// that come from outside the app (e.g. App Intents entity identifiers
24+
/// persisted in users' shortcuts), where the composite format cannot be
25+
/// assumed.
26+
public static func decomposeIfValid(
27+
_ combined: String
28+
) -> (itemType: SearchItemType, domain: String, identifier: String)? {
29+
let components = combined.components(separatedBy: separator)
30+
guard components.count == 3 else {
31+
return nil
32+
}
33+
return (SearchItemType(index: components[0]), components[1], components[2])
34+
}
1835
}
Lines changed: 313 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,313 @@
1+
import Foundation
2+
import Testing
3+
4+
@testable import WordPress
5+
@testable import WordPressData
6+
7+
@Suite("App Intent identifier parsing")
8+
struct AppIntentIdentifierParsingTests {
9+
@Test("a post identifier with a numeric domain parses")
10+
func postIdentifierWithSiteIDParses() {
11+
let identifier = AbstractPost.AppIntentIdentifier(identifier: "abstractPost|~~~|111|~~~|42")
12+
13+
#expect(identifier?.domain == "111")
14+
#expect(identifier?.postID == 42)
15+
}
16+
17+
@Test("a post identifier with an xmlrpc domain parses")
18+
func postIdentifierWithXMLRPCDomainParses() {
19+
let identifier = AbstractPost.AppIntentIdentifier(
20+
identifier: "abstractPost|~~~|https://example.com/xmlrpc.php|~~~|7"
21+
)
22+
23+
#expect(identifier?.domain == "https://example.com/xmlrpc.php")
24+
#expect(identifier?.postID == 7)
25+
}
26+
27+
@Test("invalid post identifiers do not parse")
28+
func invalidPostIdentifiersDoNotParse() {
29+
#expect(AbstractPost.AppIntentIdentifier(identifier: "readerPost|~~~|111|~~~|42") == nil)
30+
#expect(AbstractPost.AppIntentIdentifier(identifier: "abstractPost|~~~|111|~~~|not-a-number") == nil)
31+
#expect(AbstractPost.AppIntentIdentifier(identifier: "abstractPost|~~~|111") == nil)
32+
#expect(AbstractPost.AppIntentIdentifier(identifier: "garbage") == nil)
33+
}
34+
35+
@Test("a reader post identifier parses, including IDs beyond Int32")
36+
func readerPostIdentifierParses() {
37+
let identifier = ReaderPost.AppIntentIdentifier(identifier: "readerPost|~~~|3000000000|~~~|10000000000")
38+
39+
#expect(identifier?.siteID == 3_000_000_000)
40+
#expect(identifier?.postID == 10_000_000_000)
41+
}
42+
43+
@Test("invalid reader post identifiers do not parse")
44+
func invalidReaderPostIdentifiersDoNotParse() {
45+
#expect(ReaderPost.AppIntentIdentifier(identifier: "abstractPost|~~~|111|~~~|42") == nil)
46+
#expect(ReaderPost.AppIntentIdentifier(identifier: "readerPost|~~~|example.com|~~~|42") == nil)
47+
#expect(ReaderPost.AppIntentIdentifier(identifier: "readerPost|~~~|111|~~~|not-a-number") == nil)
48+
#expect(ReaderPost.AppIntentIdentifier(identifier: "garbage") == nil)
49+
}
50+
}
51+
52+
@MainActor
53+
@Suite("Post app intent resolution")
54+
struct PostAppIntentResolutionTests {
55+
@Test("a WP.com post identifier resolves the matching post")
56+
func dotComPostResolves() {
57+
let context = ContextManager.forTesting().mainContext
58+
let blog = BlogBuilder(context).with(dotComID: 111).build()
59+
let post = PostBuilder(context, blog: blog).published().build()
60+
post.postID = 42
61+
62+
#expect(AbstractPost.forAppIntent(identifier: "abstractPost|~~~|111|~~~|42", in: context) == post)
63+
}
64+
65+
@Test("a self-hosted post identifier resolves via the xmlrpc domain")
66+
func selfHostedPostResolves() {
67+
let context = ContextManager.forTesting().mainContext
68+
let blog = BlogBuilder(context, dotComID: nil).build()
69+
blog.xmlrpc = "https://example.com/xmlrpc.php"
70+
let post = PostBuilder(context, blog: blog).published().build()
71+
post.postID = 7
72+
73+
#expect(
74+
AbstractPost.forAppIntent(identifier: "abstractPost|~~~|https://example.com/xmlrpc.php|~~~|7", in: context)
75+
== post
76+
)
77+
}
78+
79+
@Test("a page identifier resolves the matching page")
80+
func pageResolves() {
81+
let context = ContextManager.forTesting().mainContext
82+
let blog = BlogBuilder(context).with(dotComID: 333).build()
83+
let page = PageBuilder(context).build()
84+
page.blog = blog
85+
page.postID = 9
86+
87+
#expect(AbstractPost.forAppIntent(identifier: "abstractPost|~~~|333|~~~|9", in: context) == page)
88+
}
89+
90+
@Test("a trashed post identifier resolves nothing")
91+
func trashedPostResolvesNil() {
92+
let context = ContextManager.forTesting().mainContext
93+
let blog = BlogBuilder(context).with(dotComID: 111).build()
94+
let post = PostBuilder(context, blog: blog).trashed().build()
95+
post.postID = 42
96+
97+
#expect(AbstractPost.forAppIntent(identifier: "abstractPost|~~~|111|~~~|42", in: context) == nil)
98+
}
99+
100+
@Test("an identifier for an unknown post resolves nothing")
101+
func unknownPostResolvesNil() {
102+
let context = ContextManager.forTesting().mainContext
103+
BlogBuilder(context).with(dotComID: 111).build()
104+
105+
#expect(AbstractPost.forAppIntent(identifier: "abstractPost|~~~|111|~~~|42", in: context) == nil)
106+
}
107+
108+
@Test("an identifier for an unknown site resolves nothing")
109+
func unknownSiteResolvesNil() {
110+
let context = ContextManager.forTesting().mainContext
111+
let post = PostBuilder(context, blog: BlogBuilder(context).with(dotComID: 111).build()).build()
112+
post.postID = 42
113+
114+
#expect(AbstractPost.forAppIntent(identifier: "abstractPost|~~~|999|~~~|42", in: context) == nil)
115+
}
116+
117+
@Test("a malformed identifier resolves nothing")
118+
func malformedIdentifierResolvesNil() {
119+
let context = ContextManager.forTesting().mainContext
120+
121+
#expect(AbstractPost.forAppIntent(identifier: "garbage", in: context) == nil)
122+
#expect(AbstractPost.forAppIntent(identifier: "abstractPost|~~~|111", in: context) == nil)
123+
#expect(AbstractPost.forAppIntent(identifier: "abstractPost|~~~|111|~~~|not-a-number", in: context) == nil)
124+
}
125+
126+
@Test("a reader post identifier passed to the post resolver resolves nothing")
127+
func wrongItemTypeResolvesNil() {
128+
let context = ContextManager.forTesting().mainContext
129+
let blog = BlogBuilder(context).with(dotComID: 111).build()
130+
let post = PostBuilder(context, blog: blog).build()
131+
post.postID = 42
132+
133+
#expect(AbstractPost.forAppIntent(identifier: "readerPost|~~~|111|~~~|42", in: context) == nil)
134+
}
135+
136+
@Test("a reader post identifier resolves the matching reader post")
137+
func readerPostResolves() {
138+
let context = ContextManager.forTesting().mainContext
139+
let post = ReaderPostBuilder(context).build()
140+
post.postID = 42
141+
post.siteID = 111
142+
143+
#expect(ReaderPost.forAppIntent(identifier: "readerPost|~~~|111|~~~|42", in: context) == post)
144+
}
145+
146+
@Test("a reader post identifier with IDs beyond Int32 resolves the matching reader post")
147+
func readerPostWithLargeIDsResolves() {
148+
let context = ContextManager.forTesting().mainContext
149+
let post = ReaderPostBuilder(context).build()
150+
post.postID = NSNumber(value: 10_000_000_000)
151+
post.siteID = NSNumber(value: 3_000_000_000)
152+
153+
#expect(ReaderPost.forAppIntent(identifier: "readerPost|~~~|3000000000|~~~|10000000000", in: context) == post)
154+
}
155+
156+
@Test("an identifier for an unknown reader post resolves nothing")
157+
func unknownReaderPostResolvesNil() {
158+
let context = ContextManager.forTesting().mainContext
159+
let post = ReaderPostBuilder(context).build()
160+
post.postID = 42
161+
post.siteID = 111
162+
163+
#expect(ReaderPost.forAppIntent(identifier: "readerPost|~~~|111|~~~|43", in: context) == nil)
164+
}
165+
166+
@Test("a post identifier passed to the reader post resolver resolves nothing")
167+
func wrongItemTypeForReaderPostResolvesNil() {
168+
let context = ContextManager.forTesting().mainContext
169+
let post = ReaderPostBuilder(context).build()
170+
post.postID = 42
171+
post.siteID = 111
172+
173+
#expect(ReaderPost.forAppIntent(identifier: "abstractPost|~~~|111|~~~|42", in: context) == nil)
174+
}
175+
}
176+
177+
@MainActor
178+
@Suite("Post app intent search")
179+
struct PostAppIntentSearchTests {
180+
@Test("matches posts and pages by title, case-insensitively")
181+
func matchesByTitle() {
182+
let context = ContextManager.forTesting().mainContext
183+
let blog = BlogBuilder(context).with(dotComID: 111).build()
184+
let post = PostBuilder(context, blog: blog).published().with(title: "Hello World").build()
185+
post.postID = 1
186+
let page = PageBuilder(context).build()
187+
page.blog = blog
188+
page.postTitle = "world tour"
189+
page.postID = 2
190+
let other = PostBuilder(context, blog: blog).published().with(title: "Something else").build()
191+
other.postID = 3
192+
193+
let results = AbstractPost.searchForAppIntent(matching: "world", in: context)
194+
195+
#expect(Set(results) == Set([post, page]))
196+
}
197+
198+
@Test("excludes trashed posts, local-only posts, and revisions")
199+
func excludesUnsearchablePosts() {
200+
let context = ContextManager.forTesting().mainContext
201+
let blog = BlogBuilder(context).with(dotComID: 111).build()
202+
let match = PostBuilder(context, blog: blog).published().with(title: "Match A").build()
203+
match.postID = 1
204+
_ = match.createRevision()
205+
let trashed = PostBuilder(context, blog: blog).trashed().with(title: "Match B").build()
206+
trashed.postID = 2
207+
PostBuilder(context, blog: blog).drafted().with(title: "Match C, local only").build()
208+
209+
let results = AbstractPost.searchForAppIntent(matching: "match", in: context)
210+
211+
#expect(results == [match])
212+
}
213+
214+
@Test("an empty query returns recent posts, most recently modified first")
215+
func emptyQueryReturnsRecentPosts() {
216+
let context = ContextManager.forTesting().mainContext
217+
let blog = BlogBuilder(context).with(dotComID: 111).build()
218+
let older = PostBuilder(context, blog: blog).published().with(title: "Older")
219+
.with(dateModified: Date(timeIntervalSince1970: 1000)).build()
220+
older.postID = 1
221+
let newer = PostBuilder(context, blog: blog).published().with(title: "Newer")
222+
.with(dateModified: Date(timeIntervalSince1970: 2000)).build()
223+
newer.postID = 2
224+
225+
let results = AbstractPost.searchForAppIntent(matching: "", in: context)
226+
227+
#expect(results == [newer, older])
228+
}
229+
230+
@Test("caps the number of results")
231+
func capsResults() {
232+
let context = ContextManager.forTesting().mainContext
233+
let blog = BlogBuilder(context).with(dotComID: 111).build()
234+
for index in 1...3 {
235+
let post = PostBuilder(context, blog: blog).published().with(title: "Post \(index)").build()
236+
post.postID = NSNumber(value: index)
237+
}
238+
239+
let results = AbstractPost.searchForAppIntent(matching: "", limit: 2, in: context)
240+
241+
#expect(results.count == 2)
242+
}
243+
244+
@Test("matches reader posts by title")
245+
func matchesReaderPostsByTitle() {
246+
let context = ContextManager.forTesting().mainContext
247+
let match = ReaderPostBuilder(context).build()
248+
match.postTitle = "Hello World"
249+
match.postID = 1
250+
match.siteID = 9
251+
let other = ReaderPostBuilder(context).build()
252+
other.postTitle = "Something else"
253+
other.postID = 2
254+
other.siteID = 9
255+
256+
let results = ReaderPost.searchForAppIntent(matching: "world", in: context)
257+
258+
#expect(results == [match])
259+
}
260+
261+
@Test("an empty query returns recent reader posts, newest first")
262+
func emptyQueryReturnsRecentReaderPosts() {
263+
let context = ContextManager.forTesting().mainContext
264+
let older = ReaderPostBuilder(context).build()
265+
older.postTitle = "Older"
266+
older.postID = 1
267+
older.siteID = 9
268+
older.sortDate = Date(timeIntervalSince1970: 1000)
269+
let newer = ReaderPostBuilder(context).build()
270+
newer.postTitle = "Newer"
271+
newer.postID = 2
272+
newer.siteID = 9
273+
newer.sortDate = Date(timeIntervalSince1970: 2000)
274+
275+
let results = ReaderPost.searchForAppIntent(matching: "", in: context)
276+
277+
#expect(results == [newer, older])
278+
}
279+
280+
@Test("reader posts cached under multiple topics are returned once")
281+
func deduplicatesReaderPosts() {
282+
let context = ContextManager.forTesting().mainContext
283+
let newest = ReaderPostBuilder(context).build()
284+
newest.postTitle = "Duplicate"
285+
newest.postID = 1
286+
newest.siteID = 9
287+
newest.sortDate = Date(timeIntervalSince1970: 3000)
288+
let duplicate = ReaderPostBuilder(context).build()
289+
duplicate.postTitle = "Duplicate"
290+
duplicate.postID = 1
291+
duplicate.siteID = 9
292+
duplicate.sortDate = Date(timeIntervalSince1970: 2000)
293+
let other = ReaderPostBuilder(context).build()
294+
other.postTitle = "Other"
295+
other.postID = 2
296+
other.siteID = 9
297+
other.sortDate = Date(timeIntervalSince1970: 1000)
298+
299+
let results = ReaderPost.searchForAppIntent(matching: "", in: context)
300+
301+
#expect(results == [newest, other])
302+
}
303+
304+
@Test("excludes reader posts that cannot be identified")
305+
func excludesUnidentifiableReaderPosts() {
306+
let context = ContextManager.forTesting().mainContext
307+
let post = ReaderPostBuilder(context).build()
308+
post.postTitle = "Hello World"
309+
post.postID = 1
310+
311+
#expect(ReaderPost.searchForAppIntent(matching: "world", in: context) == [])
312+
}
313+
}

0 commit comments

Comments
 (0)