Skip to content

Commit 80b7a0a

Browse files
committed
Prepare SearchManager seams for App Intents entities
Extract the Spotlight item routing into an internal async openItem method that App Intents can call with an entity identifier, using the failable decompose so a malformed identifier no longer crashes. openItem awaits the fetch (local cache first, network fallback) and reports the real outcome instead of returning true once the identifier parsed, and it refuses to open trashed posts; the Spotlight activity path keeps its synchronous contract by validating the identifier up front and dispatching the open. The open source is threaded through the path so Spotlight analytics fire only for genuine Spotlight opens, with intent opens untracked for now. Indexing gains an entity association hook via a protocol the Jetpack app conforms to in a Jetpack-only file; the WordPress app has no conformance and indexes unchanged.
1 parent d5ed184 commit 80b7a0a

1 file changed

Lines changed: 123 additions & 85 deletions

File tree

WordPress/Classes/Utility/Spotlight/SearchManager.swift

Lines changed: 123 additions & 85 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,35 @@ import CoreSpotlight
33
import MobileCoreServices
44
import WordPressData
55

6+
/// Adopted by `SearchManager` in the Jetpack app (in a Jetpack-only file) to
7+
/// associate App Intents entities with the Spotlight items it indexes. The
8+
/// WordPress app exposes no App Intents entities, so the conformance does not
9+
/// exist there and indexing proceeds without associations.
10+
protocol SearchableItemEntityAssociating {
11+
func associateAppEntities(from item: SearchableItemConvertable, to searchableItem: CSSearchableItem)
12+
}
13+
614
/// Encapsulates CoreSpotlight operations for WPiOS
715
///
816
@objc class SearchManager: NSObject {
917

18+
/// Where a request to open an indexed item came from. Spotlight analytics
19+
/// only fire for Spotlight-initiated opens.
20+
enum ItemSource {
21+
case spotlight
22+
case appIntent
23+
24+
/// The analytics source passed to the post preview screen.
25+
var previewAnalyticsSource: String {
26+
switch self {
27+
case .spotlight:
28+
return "spotlight_preview_post"
29+
case .appIntent:
30+
return "app_intent_preview_post"
31+
}
32+
}
33+
}
34+
1035
// MARK: - Singleton
1136

1237
@objc static let shared = SearchManager()
@@ -29,7 +54,14 @@ import WordPressData
2954
/// - items: the items to be indexed
3055
///
3156
@objc func indexItems(_ items: [SearchableItemConvertable]) {
32-
let items = items.map({ $0.indexableItem() }).compactMap({ $0 })
57+
let associating = self as? SearchableItemEntityAssociating
58+
let items = items.compactMap { item -> CSSearchableItem? in
59+
guard let searchableItem = item.indexableItem() else {
60+
return nil
61+
}
62+
associating?.associateAppEntities(from: item, to: searchableItem)
63+
return searchableItem
64+
}
3365
guard !items.isEmpty else {
3466
return
3567
}
@@ -188,62 +220,73 @@ import WordPressData
188220

189221
fileprivate func handleCoreSpotlightSearchableActivityType(activity: NSUserActivity) -> Bool {
190222
guard activity.activityType == CSSearchableItemActionType,
191-
let compositeIdentifier = activity.userInfo?[CSSearchableItemActivityIdentifier] as? String
223+
let compositeIdentifier = activity.userInfo?[CSSearchableItemActivityIdentifier] as? String,
224+
let (itemType, _, _) = SearchIdentifierGenerator.decomposeIfValid(compositeIdentifier),
225+
itemType != .none
226+
else {
227+
return false
228+
}
229+
230+
Task { @MainActor in
231+
await self.openItem(withUniqueIdentifier: compositeIdentifier, source: .spotlight)
232+
}
233+
return true
234+
}
235+
236+
/// Opens the content a composite Spotlight identifier points to, with the
237+
/// same routing as tapping the item in Spotlight. App Intents share this
238+
/// entry point because their entity identifiers use the same format.
239+
///
240+
/// - Returns: Whether the target could be resolved and put on screen, so
241+
/// callers can surface a failure instead of reporting success blindly.
242+
@discardableResult
243+
@MainActor
244+
func openItem(withUniqueIdentifier compositeIdentifier: String, source: ItemSource) async -> Bool {
245+
guard let (itemType, domainString, identifier) = SearchIdentifierGenerator.decomposeIfValid(compositeIdentifier)
192246
else {
193247
return false
194248
}
195249

196-
let (itemType, domainString, identifier) = SearchIdentifierGenerator.decomposeFromUniqueIdentifier(
197-
compositeIdentifier
198-
)
199250
switch itemType {
200251
case .abstractPost:
201-
return handleAbstractPost(domainString: domainString, identifier: identifier)
252+
return await handleAbstractPost(domainString: domainString, identifier: identifier, source: source)
202253
case .readerPost:
203-
return handleReaderPost(domainString: domainString, identifier: identifier)
204-
default:
254+
return handleReaderPost(domainString: domainString, identifier: identifier, source: source)
255+
case .none:
205256
return false
206257
}
207258
}
208259

209-
fileprivate func handleAbstractPost(domainString: String, identifier: String) -> Bool {
260+
@MainActor
261+
fileprivate func handleAbstractPost(domainString: String, identifier: String, source: ItemSource) async -> Bool {
210262
guard let postID = NumberFormatter().number(from: identifier) else {
211263
DDLogError(
212264
"Search manager unable to parse postID/siteID for identifier:\(identifier) domain:\(domainString)"
213265
)
214266
return false
215267
}
216268

269+
let post: AbstractPost?
270+
let isDotCom: Bool
217271
if let siteID = validWPComSiteID(with: domainString) {
218-
fetchPost(
219-
postID,
220-
blogID: siteID,
221-
onSuccess: { [weak self] apost in
222-
self?.navigateToScreen(for: apost)
223-
},
224-
onFailure: {
225-
DDLogError("Search manager unable to open post - postID:\(postID) siteID:\(siteID)")
226-
}
227-
)
272+
isDotCom = true
273+
post = await fetchPost(postID, blogID: siteID)
228274
} else {
229-
fetchSelfHostedPost(
230-
postID,
231-
blogXMLRpcString: domainString,
232-
onSuccess: { [weak self] apost in
233-
self?.navigateToScreen(for: apost, isDotCom: false)
234-
},
235-
onFailure: {
236-
DDLogError(
237-
"Search manager unable to open self hosted post - postID:\(postID) xmlrpc:\(domainString)"
238-
)
239-
}
240-
)
275+
isDotCom = false
276+
post = await fetchSelfHostedPost(postID, blogXMLRpcString: domainString)
241277
}
242278

279+
guard let post, post.status != .trash else {
280+
DDLogError("Search manager unable to open post - postID:\(postID) domain:\(domainString)")
281+
return false
282+
}
283+
284+
navigateToScreen(for: post, isDotCom: isDotCom, source: source)
243285
return true
244286
}
245287

246-
fileprivate func handleReaderPost(domainString: String, identifier: String) -> Bool {
288+
@MainActor
289+
fileprivate func handleReaderPost(domainString: String, identifier: String, source: ItemSource) -> Bool {
247290
guard let siteID = validWPComSiteID(with: domainString),
248291
let readerPostID = NumberFormatter().number(from: identifier)
249292
else {
@@ -252,19 +295,23 @@ import WordPressData
252295
)
253296
return false
254297
}
255-
var properties = [AnyHashable: Any]()
256-
properties[WPAppAnalyticsKeyBlogID] = siteID
257-
properties[WPAppAnalyticsKeyPostID] = readerPostID
258-
WPAppAnalytics.track(.spotlightSearchOpenedReaderPost, withProperties: properties)
298+
if source == .spotlight {
299+
var properties = [AnyHashable: Any]()
300+
properties[WPAppAnalyticsKeyBlogID] = siteID
301+
properties[WPAppAnalyticsKeyPostID] = readerPostID
302+
WPAppAnalytics.track(.spotlightSearchOpenedReaderPost, withProperties: properties)
303+
}
304+
var opened = true
259305
openReader(
260306
for: readerPostID,
261307
siteID: siteID,
262308
onFailure: {
263309
DDLogError("Search manager unable to open reader for readerPostID:\(readerPostID) siteID:\(siteID)")
310+
opened = false
264311
}
265312
)
266313

267-
return true
314+
return opened
268315
}
269316

270317
fileprivate func handleSite(activity: NSUserActivity) -> Bool {
@@ -308,53 +355,40 @@ fileprivate extension SearchManager {
308355

309356
// MARK: Fetching
310357

311-
func fetchPost(
312-
_ postID: NSNumber,
313-
blogID: NSNumber,
314-
onSuccess: @escaping (_ post: AbstractPost) -> Void,
315-
onFailure: @escaping () -> Void
316-
) {
358+
@MainActor
359+
func fetchPost(_ postID: NSNumber, blogID: NSNumber) async -> AbstractPost? {
317360
let coreDataStack = ContextManager.shared
318361

319362
guard let blog = Blog.lookup(withID: blogID, in: coreDataStack.mainContext) else {
320-
onFailure()
321-
return
322-
}
323-
324-
let postRepository = PostRepository(coreDataStack: coreDataStack)
325-
Task { @MainActor in
326-
do {
327-
let postObjectID = try await postRepository.getPost(withID: postID, from: .init(blog))
328-
let post = try coreDataStack.mainContext.existingObject(with: postObjectID)
329-
onSuccess(post)
330-
} catch {
331-
onFailure()
332-
}
363+
return nil
333364
}
365+
return await fetchPost(postID, for: blog, using: coreDataStack)
334366
}
335367

336-
func fetchSelfHostedPost(
337-
_ postID: NSNumber,
338-
blogXMLRpcString: String,
339-
onSuccess: @escaping (_ post: AbstractPost) -> Void,
340-
onFailure: @escaping () -> Void
341-
) {
368+
@MainActor
369+
func fetchSelfHostedPost(_ postID: NSNumber, blogXMLRpcString: String) async -> AbstractPost? {
342370
let coreDataStack = ContextManager.shared
343371
guard let blog = Blog.selfHosted(in: coreDataStack.mainContext).first(where: { $0.xmlrpc == blogXMLRpcString })
344372
else {
345-
onFailure()
346-
return
373+
return nil
374+
}
375+
return await fetchPost(postID, for: blog, using: coreDataStack)
376+
}
377+
378+
@MainActor
379+
func fetchPost(_ postID: NSNumber, for blog: Blog, using coreDataStack: ContextManager) async -> AbstractPost? {
380+
// A cached copy opens immediately; the network fetch covers posts
381+
// that are indexed but no longer cached locally.
382+
if let post = blog.lookupPost(withID: postID, in: coreDataStack.mainContext) {
383+
return post
347384
}
348385

349386
let postRepository = PostRepository(coreDataStack: coreDataStack)
350-
Task { @MainActor in
351-
do {
352-
let postObjectID = try await postRepository.getPost(withID: postID, from: .init(blog))
353-
let post = try coreDataStack.mainContext.existingObject(with: postObjectID)
354-
onSuccess(post)
355-
} catch {
356-
onFailure()
357-
}
387+
do {
388+
let postObjectID = try await postRepository.getPost(withID: postID, from: .init(blog))
389+
return try coreDataStack.mainContext.existingObject(with: postObjectID)
390+
} catch {
391+
return nil
358392
}
359393
}
360394

@@ -434,45 +468,49 @@ fileprivate extension SearchManager {
434468

435469
// MARK: Specific Post & Page Navigation
436470

437-
func navigateToScreen(for apost: AbstractPost, isDotCom: Bool = true) {
471+
func navigateToScreen(for apost: AbstractPost, isDotCom: Bool, source: ItemSource) {
438472
if let post = apost as? Post {
439-
self.navigateToScreen(for: post, isDotCom: isDotCom)
473+
self.navigateToScreen(for: post, isDotCom: isDotCom, source: source)
440474
} else if let page = apost as? Page {
441-
self.navigateToScreen(for: page, isDotCom: isDotCom)
475+
self.navigateToScreen(for: page, isDotCom: isDotCom, source: source)
442476
}
443477
}
444478

445-
func navigateToScreen(for post: Post, isDotCom: Bool) {
446-
WPAppAnalytics.track(.spotlightSearchOpenedPost, post: post)
479+
func navigateToScreen(for post: Post, isDotCom: Bool, source: ItemSource) {
480+
if source == .spotlight {
481+
WPAppAnalytics.track(.spotlightSearchOpenedPost, post: post)
482+
}
447483
let postIsPublishedOrScheduled = (post.status == .publish || post.status == .scheduled)
448484
if postIsPublishedOrScheduled && isDotCom {
449485
openReader(
450486
for: post,
451487
onFailure: {
452488
// If opening the reader fails, just open preview.
453-
openPreview(for: post)
489+
openPreview(for: post, source: source)
454490
}
455491
)
456492
} else if postIsPublishedOrScheduled {
457-
openPreview(for: post)
493+
openPreview(for: post, source: source)
458494
} else {
459495
openEditor(for: post)
460496
}
461497
}
462498

463-
func navigateToScreen(for page: Page, isDotCom: Bool) {
464-
WPAppAnalytics.track(.spotlightSearchOpenedPage, post: page)
499+
func navigateToScreen(for page: Page, isDotCom: Bool, source: ItemSource) {
500+
if source == .spotlight {
501+
WPAppAnalytics.track(.spotlightSearchOpenedPage, post: page)
502+
}
465503
let pageIsPublishedOrScheduled = (page.status == .publish || page.status == .scheduled)
466504
if pageIsPublishedOrScheduled && isDotCom {
467505
openReader(
468506
for: page,
469507
onFailure: {
470508
// If opening the reader fails, just open preview.
471-
openPreview(for: page)
509+
openPreview(for: page, source: source)
472510
}
473511
)
474512
} else if pageIsPublishedOrScheduled {
475-
openPreview(for: page)
513+
openPreview(for: page, source: source)
476514
} else {
477515
openEditor(for: page)
478516
}
@@ -528,11 +566,11 @@ fileprivate extension SearchManager {
528566

529567
// MARK: - Preview
530568

531-
func openPreview(for apost: AbstractPost) {
569+
func openPreview(for apost: AbstractPost, source: ItemSource) {
532570
RootViewCoordinator.sharedPresenter.showMySitesTab()
533571
closePreviewIfNeeded(for: apost)
534572

535-
let controller = PreviewWebKitViewController(post: apost, source: "spotlight_preview_post")
573+
let controller = PreviewWebKitViewController(post: apost, source: source.previewAnalyticsSource)
536574
controller.trackOpenEvent()
537575
let navWrapper = UINavigationController(rootViewController: controller)
538576
let rootViewController = RootViewCoordinator.sharedPresenter.rootViewController

0 commit comments

Comments
 (0)