@@ -3,10 +3,35 @@ import CoreSpotlight
33import MobileCoreServices
44import 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