From b3c9b860851be8eb1f152463aa5979f76954e259 Mon Sep 17 00:00:00 2001 From: Tony Li Date: Sat, 4 Jul 2026 12:30:18 +1200 Subject: [PATCH 1/5] Format Spotlight files with swift-format --- .../Swift/SearchIdentifierGenerator.swift | 9 +- .../Utility/Spotlight/SearchManager.swift | 256 ++++++++++++------ 2 files changed, 173 insertions(+), 92 deletions(-) diff --git a/Modules/Sources/WordPressData/Swift/SearchIdentifierGenerator.swift b/Modules/Sources/WordPressData/Swift/SearchIdentifierGenerator.swift index 021ecc09917d..c08226b3a863 100644 --- a/Modules/Sources/WordPressData/Swift/SearchIdentifierGenerator.swift +++ b/Modules/Sources/WordPressData/Swift/SearchIdentifierGenerator.swift @@ -3,11 +3,14 @@ import Foundation public struct SearchIdentifierGenerator { internal static let separator = "|~~~|" - internal static func composeUniqueIdentifier(itemType: SearchItemType, domain: String, identifier: String) -> String { - return "\(itemType.stringValue())\(separator)\(domain)\(separator)\(identifier)" + internal static func composeUniqueIdentifier(itemType: SearchItemType, domain: String, identifier: String) -> String + { + "\(itemType.stringValue())\(separator)\(domain)\(separator)\(identifier)" } - public static func decomposeFromUniqueIdentifier(_ combined: String) -> (itemType: SearchItemType, domain: String, identifier: String) { + public static func decomposeFromUniqueIdentifier( + _ combined: String + ) -> (itemType: SearchItemType, domain: String, identifier: String) { let components = combined.components(separatedBy: separator) return (SearchItemType(index: components[0]), components[1], components[2]) diff --git a/WordPress/Classes/Utility/Spotlight/SearchManager.swift b/WordPress/Classes/Utility/Spotlight/SearchManager.swift index 979f7cd18c6b..c1516902e59d 100644 --- a/WordPress/Classes/Utility/Spotlight/SearchManager.swift +++ b/WordPress/Classes/Utility/Spotlight/SearchManager.swift @@ -34,12 +34,16 @@ import WordPressData return } - CSSearchableIndex.default().indexSearchableItems(items, completionHandler: { (error: Error?) -> Void in - guard let error else { - return - } - DDLogError("Could not index post. Error: \(error.localizedDescription)") - }) + CSSearchableIndex.default() + .indexSearchableItems( + items, + completionHandler: { (error: Error?) -> Void in + guard let error else { + return + } + DDLogError("Could not index post. Error: \(error.localizedDescription)") + } + ) } // MARK: - Removal @@ -64,12 +68,16 @@ import WordPressData return } - CSSearchableIndex.default().deleteSearchableItems(withIdentifiers: ids, completionHandler: { (error: Error?) -> Void in - guard let error else { - return - } - DDLogError("Could not delete CSSearchableItem item. Error: \(error.localizedDescription)") - }) + CSSearchableIndex.default() + .deleteSearchableItems( + withIdentifiers: ids, + completionHandler: { (error: Error?) -> Void in + guard let error else { + return + } + DDLogError("Could not delete CSSearchableItem item. Error: \(error.localizedDescription)") + } + ) } /// Removes all items with the given domain identifier from the on-device index @@ -91,12 +99,18 @@ import WordPressData return } - CSSearchableIndex.default().deleteSearchableItems(withDomainIdentifiers: domains, completionHandler: { (error: Error?) -> Void in - guard let error else { - return - } - DDLogError("Could not delete CSSearchableItem items for domains: \(domains.joined(separator: ", ")). Error: \(error.localizedDescription)") - }) + CSSearchableIndex.default() + .deleteSearchableItems( + withDomainIdentifiers: domains, + completionHandler: { (error: Error?) -> Void in + guard let error else { + return + } + DDLogError( + "Could not delete CSSearchableItem items for domains: \(domains.joined(separator: ", ")). Error: \(error.localizedDescription)" + ) + } + ) } /// Removes *all* items from the on-device, CoreSpotlight index. @@ -105,12 +119,13 @@ import WordPressData /// if this function is called (each indexed activity item will expire automatically based on the original expiration date). /// @objc func deleteAllSearchableItems() { - CSSearchableIndex.default().deleteAllSearchableItems(completionHandler: { (error: Error?) -> Void in - guard let error else { - return - } - DDLogError("Could not delete all CSSearchableItem items. Error: \(error.localizedDescription)") - }) + CSSearchableIndex.default() + .deleteAllSearchableItems(completionHandler: { (error: Error?) -> Void in + guard let error else { + return + } + DDLogError("Could not delete all CSSearchableItem items. Error: \(error.localizedDescription)") + }) } // MARK: - NSUserActivity Handling @@ -134,7 +149,10 @@ import WordPressData WPAppAnalytics.track(.spotlightSearchOpenedApp, withProperties: ["via": WPActivityType.siteList.rawValue]) return openMySitesTab() case WPActivityType.siteDetails.rawValue: - WPAppAnalytics.track(.spotlightSearchOpenedApp, withProperties: ["via": WPActivityType.siteDetails.rawValue]) + WPAppAnalytics.track( + .spotlightSearchOpenedApp, + withProperties: ["via": WPActivityType.siteDetails.rawValue] + ) return handleSite(activity: activity) case WPActivityType.reader.rawValue: WPAppAnalytics.track(.spotlightSearchOpenedApp, withProperties: ["via": WPActivityType.reader.rawValue]) @@ -143,16 +161,25 @@ import WordPressData WPAppAnalytics.track(.spotlightSearchOpenedApp, withProperties: ["via": WPActivityType.me.rawValue]) return openMeTab() case WPActivityType.appSettings.rawValue: - WPAppAnalytics.track(.spotlightSearchOpenedApp, withProperties: ["via": WPActivityType.appSettings.rawValue]) + WPAppAnalytics.track( + .spotlightSearchOpenedApp, + withProperties: ["via": WPActivityType.appSettings.rawValue] + ) return openAppSettingsScreen() case WPActivityType.notificationSettings.rawValue: - WPAppAnalytics.track(.spotlightSearchOpenedApp, withProperties: ["via": WPActivityType.notificationSettings.rawValue]) + WPAppAnalytics.track( + .spotlightSearchOpenedApp, + withProperties: ["via": WPActivityType.notificationSettings.rawValue] + ) return openNotificationSettingsScreen() case WPActivityType.support.rawValue: WPAppAnalytics.track(.spotlightSearchOpenedApp, withProperties: ["via": WPActivityType.support.rawValue]) return openSupportScreen() case WPActivityType.notifications.rawValue: - WPAppAnalytics.track(.spotlightSearchOpenedApp, withProperties: ["via": WPActivityType.notifications.rawValue]) + WPAppAnalytics.track( + .spotlightSearchOpenedApp, + withProperties: ["via": WPActivityType.notifications.rawValue] + ) return openNotificationsTab() default: return false @@ -161,11 +188,14 @@ import WordPressData fileprivate func handleCoreSpotlightSearchableActivityType(activity: NSUserActivity) -> Bool { guard activity.activityType == CSSearchableItemActionType, - let compositeIdentifier = activity.userInfo?[CSSearchableItemActivityIdentifier] as? String else { + let compositeIdentifier = activity.userInfo?[CSSearchableItemActivityIdentifier] as? String + else { return false } - let (itemType, domainString, identifier) = SearchIdentifierGenerator.decomposeFromUniqueIdentifier(compositeIdentifier) + let (itemType, domainString, identifier) = SearchIdentifierGenerator.decomposeFromUniqueIdentifier( + compositeIdentifier + ) switch itemType { case .abstractPost: return handleAbstractPost(domainString: domainString, identifier: identifier) @@ -178,22 +208,36 @@ import WordPressData fileprivate func handleAbstractPost(domainString: String, identifier: String) -> Bool { guard let postID = NumberFormatter().number(from: identifier) else { - DDLogError("Search manager unable to parse postID/siteID for identifier:\(identifier) domain:\(domainString)") + DDLogError( + "Search manager unable to parse postID/siteID for identifier:\(identifier) domain:\(domainString)" + ) return false } if let siteID = validWPComSiteID(with: domainString) { - fetchPost(postID, blogID: siteID, onSuccess: { [weak self] apost in - self?.navigateToScreen(for: apost) - }, onFailure: { + fetchPost( + postID, + blogID: siteID, + onSuccess: { [weak self] apost in + self?.navigateToScreen(for: apost) + }, + onFailure: { DDLogError("Search manager unable to open post - postID:\(postID) siteID:\(siteID)") - }) + } + ) } else { - fetchSelfHostedPost(postID, blogXMLRpcString: domainString, onSuccess: { [weak self] apost in - self?.navigateToScreen(for: apost, isDotCom: false) - }, onFailure: { - DDLogError("Search manager unable to open self hosted post - postID:\(postID) xmlrpc:\(domainString)") - }) + fetchSelfHostedPost( + postID, + blogXMLRpcString: domainString, + onSuccess: { [weak self] apost in + self?.navigateToScreen(for: apost, isDotCom: false) + }, + onFailure: { + DDLogError( + "Search manager unable to open self hosted post - postID:\(postID) xmlrpc:\(domainString)" + ) + } + ) } return true @@ -201,39 +245,55 @@ import WordPressData fileprivate func handleReaderPost(domainString: String, identifier: String) -> Bool { guard let siteID = validWPComSiteID(with: domainString), - let readerPostID = NumberFormatter().number(from: identifier) else { - DDLogError("Search manager unable to parse postID/siteID for identifier:\(identifier) domain:\(domainString)") - return false + let readerPostID = NumberFormatter().number(from: identifier) + else { + DDLogError( + "Search manager unable to parse postID/siteID for identifier:\(identifier) domain:\(domainString)" + ) + return false } var properties = [AnyHashable: Any]() properties[WPAppAnalyticsKeyBlogID] = siteID properties[WPAppAnalyticsKeyPostID] = readerPostID WPAppAnalytics.track(.spotlightSearchOpenedReaderPost, withProperties: properties) - openReader(for: readerPostID, siteID: siteID, onFailure: { - DDLogError("Search manager unable to open reader for readerPostID:\(readerPostID) siteID:\(siteID)") - }) + openReader( + for: readerPostID, + siteID: siteID, + onFailure: { + DDLogError("Search manager unable to open reader for readerPostID:\(readerPostID) siteID:\(siteID)") + } + ) return true } fileprivate func handleSite(activity: NSUserActivity) -> Bool { guard let userInfo = activity.userInfo as? [String: Any], - let siteID = userInfo.valueAsString(forKey: WPActivityUserInfoKeys.siteId.rawValue) else { + let siteID = userInfo.valueAsString(forKey: WPActivityUserInfoKeys.siteId.rawValue) + else { return false } if let siteID = validWPComSiteID(with: siteID) { - fetchBlog(siteID, onSuccess: { [weak self] blog in - self?.openSiteDetailsScreen(for: blog) - }, onFailure: { + fetchBlog( + siteID, + onSuccess: { [weak self] blog in + self?.openSiteDetailsScreen(for: blog) + }, + onFailure: { DDLogError("Search manager unable to open site - siteID:\(siteID)") - }) + } + ) } else { - fetchSelfHostedBlog(siteID, onSuccess: { [weak self] blog in - self?.openSiteDetailsScreen(for: blog) - }, onFailure: { + fetchSelfHostedBlog( + siteID, + onSuccess: { [weak self] blog in + self?.openSiteDetailsScreen(for: blog) + }, + onFailure: { DDLogError("Search manager unable to open self hosted site - xmlrpc:\(siteID)") - }) + } + ) } return true } @@ -243,20 +303,22 @@ import WordPressData fileprivate extension SearchManager { func validWPComSiteID(with domainString: String) -> NSNumber? { - return NumberFormatter().number(from: domainString) + NumberFormatter().number(from: domainString) } // MARK: Fetching - func fetchPost(_ postID: NSNumber, - blogID: NSNumber, - onSuccess: @escaping (_ post: AbstractPost) -> Void, - onFailure: @escaping () -> Void) { + func fetchPost( + _ postID: NSNumber, + blogID: NSNumber, + onSuccess: @escaping (_ post: AbstractPost) -> Void, + onFailure: @escaping () -> Void + ) { let coreDataStack = ContextManager.shared guard let blog = Blog.lookup(withID: blogID, in: coreDataStack.mainContext) else { - onFailure() - return + onFailure() + return } let postRepository = PostRepository(coreDataStack: coreDataStack) @@ -271,12 +333,15 @@ fileprivate extension SearchManager { } } - func fetchSelfHostedPost(_ postID: NSNumber, - blogXMLRpcString: String, - onSuccess: @escaping (_ post: AbstractPost) -> Void, - onFailure: @escaping () -> Void) { + func fetchSelfHostedPost( + _ postID: NSNumber, + blogXMLRpcString: String, + onSuccess: @escaping (_ post: AbstractPost) -> Void, + onFailure: @escaping () -> Void + ) { let coreDataStack = ContextManager.shared - guard let blog = Blog.selfHosted(in: coreDataStack.mainContext).first(where: { $0.xmlrpc == blogXMLRpcString }) else { + guard let blog = Blog.selfHosted(in: coreDataStack.mainContext).first(where: { $0.xmlrpc == blogXMLRpcString }) + else { onFailure() return } @@ -293,9 +358,11 @@ fileprivate extension SearchManager { } } - func fetchBlog(_ blogID: NSNumber, - onSuccess: @escaping (_ blog: Blog) -> Void, - onFailure: @escaping () -> Void) { + func fetchBlog( + _ blogID: NSNumber, + onSuccess: @escaping (_ blog: Blog) -> Void, + onFailure: @escaping () -> Void + ) { let context = ContextManager.shared.mainContext guard let blog = Blog.lookup(withID: blogID, in: context) else { @@ -305,9 +372,11 @@ fileprivate extension SearchManager { onSuccess(blog) } - func fetchSelfHostedBlog(_ blogXMLRpcString: String, - onSuccess: @escaping (_ blog: Blog) -> Void, - onFailure: @escaping () -> Void) { + func fetchSelfHostedBlog( + _ blogXMLRpcString: String, + onSuccess: @escaping (_ blog: Blog) -> Void, + onFailure: @escaping () -> Void + ) { let context = ContextManager.shared.mainContext guard let blog = Blog.selfHosted(in: context).first(where: { $0.xmlrpc == blogXMLRpcString }) else { onFailure() @@ -377,10 +446,13 @@ fileprivate extension SearchManager { WPAppAnalytics.track(.spotlightSearchOpenedPost, post: post) let postIsPublishedOrScheduled = (post.status == .publish || post.status == .scheduled) if postIsPublishedOrScheduled && isDotCom { - openReader(for: post, onFailure: { - // If opening the reader fails, just open preview. - openPreview(for: post) - }) + openReader( + for: post, + onFailure: { + // If opening the reader fails, just open preview. + openPreview(for: post) + } + ) } else if postIsPublishedOrScheduled { openPreview(for: post) } else { @@ -392,10 +464,13 @@ fileprivate extension SearchManager { WPAppAnalytics.track(.spotlightSearchOpenedPage, post: page) let pageIsPublishedOrScheduled = (page.status == .publish || page.status == .scheduled) if pageIsPublishedOrScheduled && isDotCom { - openReader(for: page, onFailure: { - // If opening the reader fails, just open preview. - openPreview(for: page) - }) + openReader( + for: page, + onFailure: { + // If opening the reader fails, just open preview. + openPreview(for: page) + } + ) } else if pageIsPublishedOrScheduled { openPreview(for: page) } else { @@ -416,9 +491,10 @@ fileprivate extension SearchManager { closePreviewIfNeeded(for: apost) guard let postID = apost.postID, postID.intValue > 0, - let blogID = apost.blog.dotComID else { - onFailure() - return + let blogID = apost.blog.dotComID + else { + onFailure() + return } RootViewCoordinator.sharedPresenter.showReader(path: .post(postID: postID.intValue, siteID: blogID.intValue)) } @@ -478,9 +554,10 @@ fileprivate extension SearchManager { } guard let previewVC = navController.topViewController as? PreviewWebKitViewController, - previewVC.post != apost else { - // Do nothing — post is already loaded or the post preview view controller isn't visible - return + previewVC.post != apost + else { + // Do nothing — post is already loaded or the post preview view controller isn't visible + return } navController.dismiss(animated: true) @@ -491,8 +568,9 @@ fileprivate extension SearchManager { func closeAnyOpenPreview() { let rootViewController = RootViewCoordinator.sharedPresenter.rootViewController guard let navController = rootViewController.presentedViewController as? UINavigationController, - navController.topViewController is PreviewWebKitViewController else { - return + navController.topViewController is PreviewWebKitViewController + else { + return } navController.dismiss(animated: true) } From e91cbde6ea9cb45b1692318975e13def4242467d Mon Sep 17 00:00:00 2001 From: Tony Li Date: Tue, 7 Jul 2026 14:46:10 +1200 Subject: [PATCH 2/5] 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. --- .../Swift/SearchIdentifierGenerator.swift | 21 +- .../Models/PostAppIntentResolutionTests.swift | 313 ++++++++++++++++++ .../Models/AbstractPost+AppIntents.swift | 79 +++++ .../Models/ReaderPost+AppIntents.swift | 71 ++++ 4 files changed, 482 insertions(+), 2 deletions(-) create mode 100644 Tests/KeystoneTests/Tests/Models/PostAppIntentResolutionTests.swift create mode 100644 WordPress/Classes/Models/AbstractPost+AppIntents.swift create mode 100644 WordPress/Classes/Models/ReaderPost+AppIntents.swift diff --git a/Modules/Sources/WordPressData/Swift/SearchIdentifierGenerator.swift b/Modules/Sources/WordPressData/Swift/SearchIdentifierGenerator.swift index c08226b3a863..37eec4d5847c 100644 --- a/Modules/Sources/WordPressData/Swift/SearchIdentifierGenerator.swift +++ b/Modules/Sources/WordPressData/Swift/SearchIdentifierGenerator.swift @@ -3,8 +3,11 @@ import Foundation public struct SearchIdentifierGenerator { internal static let separator = "|~~~|" - internal static func composeUniqueIdentifier(itemType: SearchItemType, domain: String, identifier: String) -> String - { + internal static func composeUniqueIdentifier( + itemType: SearchItemType, + domain: String, + identifier: String + ) -> String { "\(itemType.stringValue())\(separator)\(domain)\(separator)\(identifier)" } @@ -15,4 +18,18 @@ public struct SearchIdentifierGenerator { return (SearchItemType(index: components[0]), components[1], components[2]) } + + /// A failable variant of `decomposeFromUniqueIdentifier(_:)` for identifiers + /// that come from outside the app (e.g. App Intents entity identifiers + /// persisted in users' shortcuts), where the composite format cannot be + /// assumed. + public static func decomposeIfValid( + _ combined: String + ) -> (itemType: SearchItemType, domain: String, identifier: String)? { + let components = combined.components(separatedBy: separator) + guard components.count == 3 else { + return nil + } + return (SearchItemType(index: components[0]), components[1], components[2]) + } } diff --git a/Tests/KeystoneTests/Tests/Models/PostAppIntentResolutionTests.swift b/Tests/KeystoneTests/Tests/Models/PostAppIntentResolutionTests.swift new file mode 100644 index 000000000000..84ce69c1ed8c --- /dev/null +++ b/Tests/KeystoneTests/Tests/Models/PostAppIntentResolutionTests.swift @@ -0,0 +1,313 @@ +import Foundation +import Testing + +@testable import WordPress +@testable import WordPressData + +@Suite("App Intent identifier parsing") +struct AppIntentIdentifierParsingTests { + @Test("a post identifier with a numeric domain parses") + func postIdentifierWithSiteIDParses() { + let identifier = AbstractPost.AppIntentIdentifier(identifier: "abstractPost|~~~|111|~~~|42") + + #expect(identifier?.domain == "111") + #expect(identifier?.postID == 42) + } + + @Test("a post identifier with an xmlrpc domain parses") + func postIdentifierWithXMLRPCDomainParses() { + let identifier = AbstractPost.AppIntentIdentifier( + identifier: "abstractPost|~~~|https://example.com/xmlrpc.php|~~~|7" + ) + + #expect(identifier?.domain == "https://example.com/xmlrpc.php") + #expect(identifier?.postID == 7) + } + + @Test("invalid post identifiers do not parse") + func invalidPostIdentifiersDoNotParse() { + #expect(AbstractPost.AppIntentIdentifier(identifier: "readerPost|~~~|111|~~~|42") == nil) + #expect(AbstractPost.AppIntentIdentifier(identifier: "abstractPost|~~~|111|~~~|not-a-number") == nil) + #expect(AbstractPost.AppIntentIdentifier(identifier: "abstractPost|~~~|111") == nil) + #expect(AbstractPost.AppIntentIdentifier(identifier: "garbage") == nil) + } + + @Test("a reader post identifier parses, including IDs beyond Int32") + func readerPostIdentifierParses() { + let identifier = ReaderPost.AppIntentIdentifier(identifier: "readerPost|~~~|3000000000|~~~|10000000000") + + #expect(identifier?.siteID == 3_000_000_000) + #expect(identifier?.postID == 10_000_000_000) + } + + @Test("invalid reader post identifiers do not parse") + func invalidReaderPostIdentifiersDoNotParse() { + #expect(ReaderPost.AppIntentIdentifier(identifier: "abstractPost|~~~|111|~~~|42") == nil) + #expect(ReaderPost.AppIntentIdentifier(identifier: "readerPost|~~~|example.com|~~~|42") == nil) + #expect(ReaderPost.AppIntentIdentifier(identifier: "readerPost|~~~|111|~~~|not-a-number") == nil) + #expect(ReaderPost.AppIntentIdentifier(identifier: "garbage") == nil) + } +} + +@MainActor +@Suite("Post app intent resolution") +struct PostAppIntentResolutionTests { + @Test("a WP.com post identifier resolves the matching post") + func dotComPostResolves() { + let context = ContextManager.forTesting().mainContext + let blog = BlogBuilder(context).with(dotComID: 111).build() + let post = PostBuilder(context, blog: blog).published().build() + post.postID = 42 + + #expect(AbstractPost.forAppIntent(identifier: "abstractPost|~~~|111|~~~|42", in: context) == post) + } + + @Test("a self-hosted post identifier resolves via the xmlrpc domain") + func selfHostedPostResolves() { + let context = ContextManager.forTesting().mainContext + let blog = BlogBuilder(context, dotComID: nil).build() + blog.xmlrpc = "https://example.com/xmlrpc.php" + let post = PostBuilder(context, blog: blog).published().build() + post.postID = 7 + + #expect( + AbstractPost.forAppIntent(identifier: "abstractPost|~~~|https://example.com/xmlrpc.php|~~~|7", in: context) + == post + ) + } + + @Test("a page identifier resolves the matching page") + func pageResolves() { + let context = ContextManager.forTesting().mainContext + let blog = BlogBuilder(context).with(dotComID: 333).build() + let page = PageBuilder(context).build() + page.blog = blog + page.postID = 9 + + #expect(AbstractPost.forAppIntent(identifier: "abstractPost|~~~|333|~~~|9", in: context) == page) + } + + @Test("a trashed post identifier resolves nothing") + func trashedPostResolvesNil() { + let context = ContextManager.forTesting().mainContext + let blog = BlogBuilder(context).with(dotComID: 111).build() + let post = PostBuilder(context, blog: blog).trashed().build() + post.postID = 42 + + #expect(AbstractPost.forAppIntent(identifier: "abstractPost|~~~|111|~~~|42", in: context) == nil) + } + + @Test("an identifier for an unknown post resolves nothing") + func unknownPostResolvesNil() { + let context = ContextManager.forTesting().mainContext + BlogBuilder(context).with(dotComID: 111).build() + + #expect(AbstractPost.forAppIntent(identifier: "abstractPost|~~~|111|~~~|42", in: context) == nil) + } + + @Test("an identifier for an unknown site resolves nothing") + func unknownSiteResolvesNil() { + let context = ContextManager.forTesting().mainContext + let post = PostBuilder(context, blog: BlogBuilder(context).with(dotComID: 111).build()).build() + post.postID = 42 + + #expect(AbstractPost.forAppIntent(identifier: "abstractPost|~~~|999|~~~|42", in: context) == nil) + } + + @Test("a malformed identifier resolves nothing") + func malformedIdentifierResolvesNil() { + let context = ContextManager.forTesting().mainContext + + #expect(AbstractPost.forAppIntent(identifier: "garbage", in: context) == nil) + #expect(AbstractPost.forAppIntent(identifier: "abstractPost|~~~|111", in: context) == nil) + #expect(AbstractPost.forAppIntent(identifier: "abstractPost|~~~|111|~~~|not-a-number", in: context) == nil) + } + + @Test("a reader post identifier passed to the post resolver resolves nothing") + func wrongItemTypeResolvesNil() { + let context = ContextManager.forTesting().mainContext + let blog = BlogBuilder(context).with(dotComID: 111).build() + let post = PostBuilder(context, blog: blog).build() + post.postID = 42 + + #expect(AbstractPost.forAppIntent(identifier: "readerPost|~~~|111|~~~|42", in: context) == nil) + } + + @Test("a reader post identifier resolves the matching reader post") + func readerPostResolves() { + let context = ContextManager.forTesting().mainContext + let post = ReaderPostBuilder(context).build() + post.postID = 42 + post.siteID = 111 + + #expect(ReaderPost.forAppIntent(identifier: "readerPost|~~~|111|~~~|42", in: context) == post) + } + + @Test("a reader post identifier with IDs beyond Int32 resolves the matching reader post") + func readerPostWithLargeIDsResolves() { + let context = ContextManager.forTesting().mainContext + let post = ReaderPostBuilder(context).build() + post.postID = NSNumber(value: 10_000_000_000) + post.siteID = NSNumber(value: 3_000_000_000) + + #expect(ReaderPost.forAppIntent(identifier: "readerPost|~~~|3000000000|~~~|10000000000", in: context) == post) + } + + @Test("an identifier for an unknown reader post resolves nothing") + func unknownReaderPostResolvesNil() { + let context = ContextManager.forTesting().mainContext + let post = ReaderPostBuilder(context).build() + post.postID = 42 + post.siteID = 111 + + #expect(ReaderPost.forAppIntent(identifier: "readerPost|~~~|111|~~~|43", in: context) == nil) + } + + @Test("a post identifier passed to the reader post resolver resolves nothing") + func wrongItemTypeForReaderPostResolvesNil() { + let context = ContextManager.forTesting().mainContext + let post = ReaderPostBuilder(context).build() + post.postID = 42 + post.siteID = 111 + + #expect(ReaderPost.forAppIntent(identifier: "abstractPost|~~~|111|~~~|42", in: context) == nil) + } +} + +@MainActor +@Suite("Post app intent search") +struct PostAppIntentSearchTests { + @Test("matches posts and pages by title, case-insensitively") + func matchesByTitle() { + let context = ContextManager.forTesting().mainContext + let blog = BlogBuilder(context).with(dotComID: 111).build() + let post = PostBuilder(context, blog: blog).published().with(title: "Hello World").build() + post.postID = 1 + let page = PageBuilder(context).build() + page.blog = blog + page.postTitle = "world tour" + page.postID = 2 + let other = PostBuilder(context, blog: blog).published().with(title: "Something else").build() + other.postID = 3 + + let results = AbstractPost.searchForAppIntent(matching: "world", in: context) + + #expect(Set(results) == Set([post, page])) + } + + @Test("excludes trashed posts, local-only posts, and revisions") + func excludesUnsearchablePosts() { + let context = ContextManager.forTesting().mainContext + let blog = BlogBuilder(context).with(dotComID: 111).build() + let match = PostBuilder(context, blog: blog).published().with(title: "Match A").build() + match.postID = 1 + _ = match.createRevision() + let trashed = PostBuilder(context, blog: blog).trashed().with(title: "Match B").build() + trashed.postID = 2 + PostBuilder(context, blog: blog).drafted().with(title: "Match C, local only").build() + + let results = AbstractPost.searchForAppIntent(matching: "match", in: context) + + #expect(results == [match]) + } + + @Test("an empty query returns recent posts, most recently modified first") + func emptyQueryReturnsRecentPosts() { + let context = ContextManager.forTesting().mainContext + let blog = BlogBuilder(context).with(dotComID: 111).build() + let older = PostBuilder(context, blog: blog).published().with(title: "Older") + .with(dateModified: Date(timeIntervalSince1970: 1000)).build() + older.postID = 1 + let newer = PostBuilder(context, blog: blog).published().with(title: "Newer") + .with(dateModified: Date(timeIntervalSince1970: 2000)).build() + newer.postID = 2 + + let results = AbstractPost.searchForAppIntent(matching: "", in: context) + + #expect(results == [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).published().with(title: "Post \(index)").build() + post.postID = NSNumber(value: index) + } + + let results = AbstractPost.searchForAppIntent(matching: "", limit: 2, in: context) + + #expect(results.count == 2) + } + + @Test("matches reader posts by title") + func matchesReaderPostsByTitle() { + let context = ContextManager.forTesting().mainContext + let match = ReaderPostBuilder(context).build() + match.postTitle = "Hello World" + match.postID = 1 + match.siteID = 9 + let other = ReaderPostBuilder(context).build() + other.postTitle = "Something else" + other.postID = 2 + other.siteID = 9 + + let results = ReaderPost.searchForAppIntent(matching: "world", in: context) + + #expect(results == [match]) + } + + @Test("an empty query returns recent reader posts, newest first") + func emptyQueryReturnsRecentReaderPosts() { + let context = ContextManager.forTesting().mainContext + let older = ReaderPostBuilder(context).build() + older.postTitle = "Older" + older.postID = 1 + older.siteID = 9 + older.sortDate = Date(timeIntervalSince1970: 1000) + let newer = ReaderPostBuilder(context).build() + newer.postTitle = "Newer" + newer.postID = 2 + newer.siteID = 9 + newer.sortDate = Date(timeIntervalSince1970: 2000) + + let results = ReaderPost.searchForAppIntent(matching: "", in: context) + + #expect(results == [newer, older]) + } + + @Test("reader posts cached under multiple topics are returned once") + func deduplicatesReaderPosts() { + let context = ContextManager.forTesting().mainContext + let newest = ReaderPostBuilder(context).build() + newest.postTitle = "Duplicate" + newest.postID = 1 + newest.siteID = 9 + newest.sortDate = Date(timeIntervalSince1970: 3000) + let duplicate = ReaderPostBuilder(context).build() + duplicate.postTitle = "Duplicate" + duplicate.postID = 1 + duplicate.siteID = 9 + duplicate.sortDate = Date(timeIntervalSince1970: 2000) + let other = ReaderPostBuilder(context).build() + other.postTitle = "Other" + other.postID = 2 + other.siteID = 9 + other.sortDate = Date(timeIntervalSince1970: 1000) + + let results = ReaderPost.searchForAppIntent(matching: "", in: context) + + #expect(results == [newest, other]) + } + + @Test("excludes reader posts that cannot be identified") + func excludesUnidentifiableReaderPosts() { + let context = ContextManager.forTesting().mainContext + let post = ReaderPostBuilder(context).build() + post.postTitle = "Hello World" + post.postID = 1 + + #expect(ReaderPost.searchForAppIntent(matching: "world", in: context) == []) + } +} diff --git a/WordPress/Classes/Models/AbstractPost+AppIntents.swift b/WordPress/Classes/Models/AbstractPost+AppIntents.swift new file mode 100644 index 000000000000..1f6256debc57 --- /dev/null +++ b/WordPress/Classes/Models/AbstractPost+AppIntents.swift @@ -0,0 +1,79 @@ +import Foundation +import WordPressData + +extension AbstractPost { + /// The parsed components of a post App Intent entity identifier + /// (`abstractPost|~~~||~~~|`), shared by the + /// Core Data resolver and the placeholder entities that represent posts + /// missing from the local store. + struct AppIntentIdentifier { + /// A WP.com site ID when numeric, the site's xmlrpc URL otherwise, + /// matching the Spotlight identifier convention. + let domain: String + let postID: Int + + init?(identifier: String) { + guard let (itemType, domain, postIDString) = SearchIdentifierGenerator.decomposeIfValid(identifier), + itemType == .abstractPost, + let postID = Int(postIDString) + else { + return nil + } + self.domain = domain + self.postID = postID + } + } + + /// Resolves the post an App Intent entity identifier refers to. + /// + /// The identifier is the same composite string the Spotlight index uses + /// (`abstractPost|~~~||~~~|`), so a Spotlight + /// item and its associated app entity always name the same post. An + /// identifier that cannot be resolved returns `nil` rather than falling + /// back to another post. Trashed posts resolve to `nil` because their + /// Spotlight items are deleted on trashing and the editor must not open + /// them from a stale shortcut. + static func forAppIntent(identifier: String, in context: NSManagedObjectContext) -> AbstractPost? { + guard let identifier = AppIntentIdentifier(identifier: identifier) else { + return nil + } + + let blog: Blog? + if let siteID = Int(identifier.domain) { + blog = try? Blog.lookup(withID: siteID, in: context) + } else { + blog = try? BlogQuery().hostedByWPCom(false).xmlrpc(matching: identifier.domain).blog(in: context) + } + + guard let blog, + let post = blog.lookupPost(withID: identifier.postID, in: context), + post.status != .trash + else { + return nil + } + return post + } + + /// Returns posts and pages an App Intent entity query can offer, newest + /// first. An empty query returns the most recently modified posts. Only + /// posts that can be identified by `forAppIntent(identifier:in:)` are + /// returned. + static func searchForAppIntent( + matching query: String, + limit: Int = 20, + in context: NSManagedObjectContext + ) -> [AbstractPost] { + let request = NSFetchRequest(entityName: AbstractPost.entityName()) + var predicates = [ + NSPredicate(format: "original = NULL AND postID > 0"), + NSPredicate(format: "status != %@", BasePost.Status.trash.rawValue) + ] + if !query.isEmpty { + predicates.append(NSPredicate(format: "postTitle CONTAINS[cd] %@", query)) + } + request.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: predicates) + request.sortDescriptors = [NSSortDescriptor(key: #keyPath(AbstractPost.dateModified), ascending: false)] + request.fetchLimit = limit + return (try? context.fetch(request)) ?? [] + } +} diff --git a/WordPress/Classes/Models/ReaderPost+AppIntents.swift b/WordPress/Classes/Models/ReaderPost+AppIntents.swift new file mode 100644 index 000000000000..62f64a22bc62 --- /dev/null +++ b/WordPress/Classes/Models/ReaderPost+AppIntents.swift @@ -0,0 +1,71 @@ +import Foundation +import WordPressData + +extension ReaderPost { + /// The parsed components of a reader post App Intent entity identifier + /// (`readerPost|~~~||~~~|`), shared by the Core Data + /// resolver and the placeholder entities that represent posts missing + /// from the local store. + struct AppIntentIdentifier { + let siteID: Int + let postID: Int + + init?(identifier: String) { + guard let (itemType, domain, postIDString) = SearchIdentifierGenerator.decomposeIfValid(identifier), + itemType == .readerPost, + let siteID = Int(domain), + let postID = Int(postIDString) + else { + return nil + } + self.siteID = siteID + self.postID = postID + } + } + + /// Resolves the reader post an App Intent entity identifier refers to. + /// + /// The identifier is the same composite string the Spotlight index uses + /// (`readerPost|~~~||~~~|`). An identifier that cannot be + /// resolved returns `nil` rather than falling back to another post. + static func forAppIntent(identifier: String, in context: NSManagedObjectContext) -> ReaderPost? { + guard let identifier = AppIntentIdentifier(identifier: identifier) else { + return nil + } + + return try? ReaderPost.lookup( + withID: NSNumber(value: identifier.postID), + forSiteWithID: NSNumber(value: identifier.siteID), + in: context + ) + } + + /// Returns reader posts an App Intent entity query can offer, newest + /// first. An empty query returns the most recent posts. Only posts that + /// can be identified by `forAppIntent(identifier:in:)` are returned. + static func searchForAppIntent( + matching query: String, + limit: Int = 20, + in context: NSManagedObjectContext + ) -> [ReaderPost] { + let request = NSFetchRequest(entityName: ReaderPost.classNameWithoutNamespaces()) + var predicates = [NSPredicate(format: "postID > 0 AND siteID > 0")] + if !query.isEmpty { + predicates.append(NSPredicate(format: "postTitle CONTAINS[cd] %@", query)) + } + request.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: predicates) + request.sortDescriptors = [NSSortDescriptor(key: #keyPath(ReaderPost.sortDate), ascending: false)] + request.fetchLimit = limit + let posts = (try? context.fetch(request)) ?? [] + // The same remote post is cached once per Reader topic; keep only the + // first (newest) row for each site/post pair so the entity ids stay + // unique. + var seen = Set() + return posts.filter { post in + guard let identifier = post.uniqueIdentifier else { + return false + } + return seen.insert(identifier).inserted + } + } +} From 3b012cafb160f877e0be4106f9bc421af8c2d1a0 Mon Sep 17 00:00:00 2001 From: Tony Li Date: Tue, 7 Jul 2026 14:46:10 +1200 Subject: [PATCH 3/5] 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. --- .../Utility/Spotlight/SearchManager.swift | 208 +++++++++++------- 1 file changed, 123 insertions(+), 85 deletions(-) diff --git a/WordPress/Classes/Utility/Spotlight/SearchManager.swift b/WordPress/Classes/Utility/Spotlight/SearchManager.swift index c1516902e59d..e9b539414a4f 100644 --- a/WordPress/Classes/Utility/Spotlight/SearchManager.swift +++ b/WordPress/Classes/Utility/Spotlight/SearchManager.swift @@ -3,10 +3,35 @@ import CoreSpotlight import MobileCoreServices import WordPressData +/// Adopted by `SearchManager` in the Jetpack app (in a Jetpack-only file) to +/// associate App Intents entities with the Spotlight items it indexes. The +/// WordPress app exposes no App Intents entities, so the conformance does not +/// exist there and indexing proceeds without associations. +protocol SearchableItemEntityAssociating { + func associateAppEntities(from item: SearchableItemConvertable, to searchableItem: CSSearchableItem) +} + /// Encapsulates CoreSpotlight operations for WPiOS /// @objc class SearchManager: NSObject { + /// Where a request to open an indexed item came from. Spotlight analytics + /// only fire for Spotlight-initiated opens. + enum ItemSource { + case spotlight + case appIntent + + /// The analytics source passed to the post preview screen. + var previewAnalyticsSource: String { + switch self { + case .spotlight: + return "spotlight_preview_post" + case .appIntent: + return "app_intent_preview_post" + } + } + } + // MARK: - Singleton @objc static let shared = SearchManager() @@ -29,7 +54,14 @@ import WordPressData /// - items: the items to be indexed /// @objc func indexItems(_ items: [SearchableItemConvertable]) { - let items = items.map({ $0.indexableItem() }).compactMap({ $0 }) + let associating = self as? SearchableItemEntityAssociating + let items = items.compactMap { item -> CSSearchableItem? in + guard let searchableItem = item.indexableItem() else { + return nil + } + associating?.associateAppEntities(from: item, to: searchableItem) + return searchableItem + } guard !items.isEmpty else { return } @@ -188,25 +220,45 @@ import WordPressData fileprivate func handleCoreSpotlightSearchableActivityType(activity: NSUserActivity) -> Bool { guard activity.activityType == CSSearchableItemActionType, - let compositeIdentifier = activity.userInfo?[CSSearchableItemActivityIdentifier] as? String + let compositeIdentifier = activity.userInfo?[CSSearchableItemActivityIdentifier] as? String, + let (itemType, _, _) = SearchIdentifierGenerator.decomposeIfValid(compositeIdentifier), + itemType != .none + else { + return false + } + + Task { @MainActor in + await self.openItem(withUniqueIdentifier: compositeIdentifier, source: .spotlight) + } + return true + } + + /// Opens the content a composite Spotlight identifier points to, with the + /// same routing as tapping the item in Spotlight. App Intents share this + /// entry point because their entity identifiers use the same format. + /// + /// - Returns: Whether the target could be resolved and put on screen, so + /// callers can surface a failure instead of reporting success blindly. + @discardableResult + @MainActor + func openItem(withUniqueIdentifier compositeIdentifier: String, source: ItemSource) async -> Bool { + guard let (itemType, domainString, identifier) = SearchIdentifierGenerator.decomposeIfValid(compositeIdentifier) else { return false } - let (itemType, domainString, identifier) = SearchIdentifierGenerator.decomposeFromUniqueIdentifier( - compositeIdentifier - ) switch itemType { case .abstractPost: - return handleAbstractPost(domainString: domainString, identifier: identifier) + return await handleAbstractPost(domainString: domainString, identifier: identifier, source: source) case .readerPost: - return handleReaderPost(domainString: domainString, identifier: identifier) - default: + return handleReaderPost(domainString: domainString, identifier: identifier, source: source) + case .none: return false } } - fileprivate func handleAbstractPost(domainString: String, identifier: String) -> Bool { + @MainActor + fileprivate func handleAbstractPost(domainString: String, identifier: String, source: ItemSource) async -> Bool { guard let postID = NumberFormatter().number(from: identifier) else { DDLogError( "Search manager unable to parse postID/siteID for identifier:\(identifier) domain:\(domainString)" @@ -214,36 +266,27 @@ import WordPressData return false } + let post: AbstractPost? + let isDotCom: Bool if let siteID = validWPComSiteID(with: domainString) { - fetchPost( - postID, - blogID: siteID, - onSuccess: { [weak self] apost in - self?.navigateToScreen(for: apost) - }, - onFailure: { - DDLogError("Search manager unable to open post - postID:\(postID) siteID:\(siteID)") - } - ) + isDotCom = true + post = await fetchPost(postID, blogID: siteID) } else { - fetchSelfHostedPost( - postID, - blogXMLRpcString: domainString, - onSuccess: { [weak self] apost in - self?.navigateToScreen(for: apost, isDotCom: false) - }, - onFailure: { - DDLogError( - "Search manager unable to open self hosted post - postID:\(postID) xmlrpc:\(domainString)" - ) - } - ) + isDotCom = false + post = await fetchSelfHostedPost(postID, blogXMLRpcString: domainString) } + guard let post, post.status != .trash else { + DDLogError("Search manager unable to open post - postID:\(postID) domain:\(domainString)") + return false + } + + navigateToScreen(for: post, isDotCom: isDotCom, source: source) return true } - fileprivate func handleReaderPost(domainString: String, identifier: String) -> Bool { + @MainActor + fileprivate func handleReaderPost(domainString: String, identifier: String, source: ItemSource) -> Bool { guard let siteID = validWPComSiteID(with: domainString), let readerPostID = NumberFormatter().number(from: identifier) else { @@ -252,19 +295,23 @@ import WordPressData ) return false } - var properties = [AnyHashable: Any]() - properties[WPAppAnalyticsKeyBlogID] = siteID - properties[WPAppAnalyticsKeyPostID] = readerPostID - WPAppAnalytics.track(.spotlightSearchOpenedReaderPost, withProperties: properties) + if source == .spotlight { + var properties = [AnyHashable: Any]() + properties[WPAppAnalyticsKeyBlogID] = siteID + properties[WPAppAnalyticsKeyPostID] = readerPostID + WPAppAnalytics.track(.spotlightSearchOpenedReaderPost, withProperties: properties) + } + var opened = true openReader( for: readerPostID, siteID: siteID, onFailure: { DDLogError("Search manager unable to open reader for readerPostID:\(readerPostID) siteID:\(siteID)") + opened = false } ) - return true + return opened } fileprivate func handleSite(activity: NSUserActivity) -> Bool { @@ -308,53 +355,40 @@ fileprivate extension SearchManager { // MARK: Fetching - func fetchPost( - _ postID: NSNumber, - blogID: NSNumber, - onSuccess: @escaping (_ post: AbstractPost) -> Void, - onFailure: @escaping () -> Void - ) { + @MainActor + func fetchPost(_ postID: NSNumber, blogID: NSNumber) async -> AbstractPost? { let coreDataStack = ContextManager.shared guard let blog = Blog.lookup(withID: blogID, in: coreDataStack.mainContext) else { - onFailure() - return - } - - let postRepository = PostRepository(coreDataStack: coreDataStack) - Task { @MainActor in - do { - let postObjectID = try await postRepository.getPost(withID: postID, from: .init(blog)) - let post = try coreDataStack.mainContext.existingObject(with: postObjectID) - onSuccess(post) - } catch { - onFailure() - } + return nil } + return await fetchPost(postID, for: blog, using: coreDataStack) } - func fetchSelfHostedPost( - _ postID: NSNumber, - blogXMLRpcString: String, - onSuccess: @escaping (_ post: AbstractPost) -> Void, - onFailure: @escaping () -> Void - ) { + @MainActor + func fetchSelfHostedPost(_ postID: NSNumber, blogXMLRpcString: String) async -> AbstractPost? { let coreDataStack = ContextManager.shared guard let blog = Blog.selfHosted(in: coreDataStack.mainContext).first(where: { $0.xmlrpc == blogXMLRpcString }) else { - onFailure() - return + return nil + } + return await fetchPost(postID, for: blog, using: coreDataStack) + } + + @MainActor + func fetchPost(_ postID: NSNumber, for blog: Blog, using coreDataStack: ContextManager) async -> AbstractPost? { + // A cached copy opens immediately; the network fetch covers posts + // that are indexed but no longer cached locally. + if let post = blog.lookupPost(withID: postID, in: coreDataStack.mainContext) { + return post } let postRepository = PostRepository(coreDataStack: coreDataStack) - Task { @MainActor in - do { - let postObjectID = try await postRepository.getPost(withID: postID, from: .init(blog)) - let post = try coreDataStack.mainContext.existingObject(with: postObjectID) - onSuccess(post) - } catch { - onFailure() - } + do { + let postObjectID = try await postRepository.getPost(withID: postID, from: .init(blog)) + return try coreDataStack.mainContext.existingObject(with: postObjectID) + } catch { + return nil } } @@ -434,45 +468,49 @@ fileprivate extension SearchManager { // MARK: Specific Post & Page Navigation - func navigateToScreen(for apost: AbstractPost, isDotCom: Bool = true) { + func navigateToScreen(for apost: AbstractPost, isDotCom: Bool, source: ItemSource) { if let post = apost as? Post { - self.navigateToScreen(for: post, isDotCom: isDotCom) + self.navigateToScreen(for: post, isDotCom: isDotCom, source: source) } else if let page = apost as? Page { - self.navigateToScreen(for: page, isDotCom: isDotCom) + self.navigateToScreen(for: page, isDotCom: isDotCom, source: source) } } - func navigateToScreen(for post: Post, isDotCom: Bool) { - WPAppAnalytics.track(.spotlightSearchOpenedPost, post: post) + func navigateToScreen(for post: Post, isDotCom: Bool, source: ItemSource) { + if source == .spotlight { + WPAppAnalytics.track(.spotlightSearchOpenedPost, post: post) + } let postIsPublishedOrScheduled = (post.status == .publish || post.status == .scheduled) if postIsPublishedOrScheduled && isDotCom { openReader( for: post, onFailure: { // If opening the reader fails, just open preview. - openPreview(for: post) + openPreview(for: post, source: source) } ) } else if postIsPublishedOrScheduled { - openPreview(for: post) + openPreview(for: post, source: source) } else { openEditor(for: post) } } - func navigateToScreen(for page: Page, isDotCom: Bool) { - WPAppAnalytics.track(.spotlightSearchOpenedPage, post: page) + func navigateToScreen(for page: Page, isDotCom: Bool, source: ItemSource) { + if source == .spotlight { + WPAppAnalytics.track(.spotlightSearchOpenedPage, post: page) + } let pageIsPublishedOrScheduled = (page.status == .publish || page.status == .scheduled) if pageIsPublishedOrScheduled && isDotCom { openReader( for: page, onFailure: { // If opening the reader fails, just open preview. - openPreview(for: page) + openPreview(for: page, source: source) } ) } else if pageIsPublishedOrScheduled { - openPreview(for: page) + openPreview(for: page, source: source) } else { openEditor(for: page) } @@ -528,11 +566,11 @@ fileprivate extension SearchManager { // MARK: - Preview - func openPreview(for apost: AbstractPost) { + func openPreview(for apost: AbstractPost, source: ItemSource) { RootViewCoordinator.sharedPresenter.showMySitesTab() closePreviewIfNeeded(for: apost) - let controller = PreviewWebKitViewController(post: apost, source: "spotlight_preview_post") + let controller = PreviewWebKitViewController(post: apost, source: source.previewAnalyticsSource) controller.trackOpenEvent() let navWrapper = UINavigationController(rootViewController: controller) let rootViewController = RootViewCoordinator.sharedPresenter.rootViewController From 0a263fa768da2e41070ddef40ba262070c6349f9 Mon Sep 17 00:00:00 2001 From: Tony Li Date: Tue, 7 Jul 2026 14:46:10 +1200 Subject: [PATCH 4/5] Add post and reader post App Intents entities to the Jetpack app PostEntity covers posts and pages; both entities use the Spotlight composite identifier as their entity ID, the string queries search the local Core Data store, and OpenPostIntent/OpenReaderPostIntent require a signed-in account and navigate through the same routing as a Spotlight tap, reporting its real outcome. A well-formed identifier missing from the local store (Reader purges, reinstalls) resolves to a placeholder entity instead of dropping the saved shortcut, since the open path can still handle it remotely. On iOS 18 and later, newly indexed Spotlight items get the matching entity associated. Strings use the ios-appintents. GlotPress keys. --- .../AppIntents/AppIntentOpenError.swift | 6 ++ .../Jetpack/AppIntents/OpenPostIntent.swift | 28 +++++++++ .../AppIntents/OpenReaderPostIntent.swift | 28 +++++++++ Sources/Jetpack/AppIntents/PostEntity.swift | 62 +++++++++++++++++++ .../Jetpack/AppIntents/PostEntityQuery.swift | 35 +++++++++++ .../Jetpack/AppIntents/ReaderPostEntity.swift | 56 +++++++++++++++++ .../AppIntents/ReaderPostEntityQuery.swift | 38 ++++++++++++ .../SearchManager+EntityAssociation.swift | 26 ++++++++ .../en.lproj/Localizable.strings | 36 +++++++++++ 9 files changed, 315 insertions(+) create mode 100644 Sources/Jetpack/AppIntents/OpenPostIntent.swift create mode 100644 Sources/Jetpack/AppIntents/OpenReaderPostIntent.swift create mode 100644 Sources/Jetpack/AppIntents/PostEntity.swift create mode 100644 Sources/Jetpack/AppIntents/PostEntityQuery.swift create mode 100644 Sources/Jetpack/AppIntents/ReaderPostEntity.swift create mode 100644 Sources/Jetpack/AppIntents/ReaderPostEntityQuery.swift create mode 100644 Sources/Jetpack/AppIntents/SearchManager+EntityAssociation.swift diff --git a/Sources/Jetpack/AppIntents/AppIntentOpenError.swift b/Sources/Jetpack/AppIntents/AppIntentOpenError.swift index 5f29adf4896d..af7a46d43e9b 100644 --- a/Sources/Jetpack/AppIntents/AppIntentOpenError.swift +++ b/Sources/Jetpack/AppIntents/AppIntentOpenError.swift @@ -5,6 +5,7 @@ import Foundation enum AppIntentOpenError: Error, CustomLocalizedStringResourceConvertible { case notLoggedIn case siteNotFound + case postNotFound var localizedStringResource: LocalizedStringResource { switch self { @@ -18,6 +19,11 @@ enum AppIntentOpenError: Error, CustomLocalizedStringResourceConvertible { "ios-appintents.openError.siteNotFound", defaultValue: "The site could not be found." ) + case .postNotFound: + return LocalizedStringResource( + "ios-appintents.openError.postNotFound", + defaultValue: "The post could not be found." + ) } } } diff --git a/Sources/Jetpack/AppIntents/OpenPostIntent.swift b/Sources/Jetpack/AppIntents/OpenPostIntent.swift new file mode 100644 index 000000000000..d7decf7442c5 --- /dev/null +++ b/Sources/Jetpack/AppIntents/OpenPostIntent.swift @@ -0,0 +1,28 @@ +import AppIntents +import Foundation + +/// Opens a post or page in the app, with the same routing as tapping it in Spotlight. +struct OpenPostIntent: AppIntent { + static let title = LocalizedStringResource("ios-appintents.openPost.title", defaultValue: "Open Post") + static let description = IntentDescription( + LocalizedStringResource( + "ios-appintents.openPost.description", + defaultValue: "Opens one of your posts or pages in the app." + ) + ) + static let openAppWhenRun = true + + @Parameter(title: LocalizedStringResource("ios-appintents.openPost.postParameter", defaultValue: "Post")) + var post: PostEntity + + @MainActor + func perform() async throws -> some IntentResult { + guard AccountHelper.isLoggedIn else { + throw AppIntentOpenError.notLoggedIn + } + guard await SearchManager.shared.openItem(withUniqueIdentifier: post.id, source: .appIntent) else { + throw AppIntentOpenError.postNotFound + } + return .result() + } +} diff --git a/Sources/Jetpack/AppIntents/OpenReaderPostIntent.swift b/Sources/Jetpack/AppIntents/OpenReaderPostIntent.swift new file mode 100644 index 000000000000..59735f23a31a --- /dev/null +++ b/Sources/Jetpack/AppIntents/OpenReaderPostIntent.swift @@ -0,0 +1,28 @@ +import AppIntents +import Foundation + +/// Opens a Reader post in the app, with the same routing as tapping it in Spotlight. +struct OpenReaderPostIntent: AppIntent { + static let title = LocalizedStringResource("ios-appintents.openReaderPost.title", defaultValue: "Open Reader Post") + static let description = IntentDescription( + LocalizedStringResource( + "ios-appintents.openReaderPost.description", + defaultValue: "Opens a post from your Reader in the app." + ) + ) + static let openAppWhenRun = true + + @Parameter(title: LocalizedStringResource("ios-appintents.openReaderPost.postParameter", defaultValue: "Post")) + var post: ReaderPostEntity + + @MainActor + func perform() async throws -> some IntentResult { + guard AccountHelper.isLoggedIn else { + throw AppIntentOpenError.notLoggedIn + } + guard await SearchManager.shared.openItem(withUniqueIdentifier: post.id, source: .appIntent) else { + throw AppIntentOpenError.postNotFound + } + return .result() + } +} diff --git a/Sources/Jetpack/AppIntents/PostEntity.swift b/Sources/Jetpack/AppIntents/PostEntity.swift new file mode 100644 index 000000000000..4d4532fa5d23 --- /dev/null +++ b/Sources/Jetpack/AppIntents/PostEntity.swift @@ -0,0 +1,62 @@ +import AppIntents +import Foundation +import WordPressData + +/// A post or page on one of the user's sites, as exposed to the system via App Intents. +/// +/// The identifier is the same composite string the Spotlight index uses, so a Spotlight +/// item and its associated app entity always name the same post. +struct PostEntity: AppEntity { + static let typeDisplayRepresentation = TypeDisplayRepresentation( + name: LocalizedStringResource("ios-appintents.postEntity.typeName", defaultValue: "Post") + ) + + static var defaultQuery: PostEntityQuery { PostEntityQuery() } + + let id: String + let title: String + let siteName: String? + let isPage: Bool + + var displayRepresentation: DisplayRepresentation { + let kind = + isPage + ? String(localized: LocalizedStringResource("ios-appintents.postEntity.pageKind", defaultValue: "Page")) + : nil + let subtitle = [kind, siteName].compactMap { $0 }.joined(separator: " · ") + guard !subtitle.isEmpty else { + return DisplayRepresentation(title: "\(title)") + } + return DisplayRepresentation(title: "\(title)", subtitle: "\(subtitle)") + } + + /// Fails for posts that only exist locally: without a remote post ID there + /// is no stable identifier to expose. + init?(post: AbstractPost) { + guard let identifier = post.uniqueIdentifier else { + return nil + } + self.id = identifier + self.title = post.titleForDisplay() + self.siteName = post.blog.settings?.name ?? post.blog.displayURL as String? + self.isPage = post is Page + } + + /// A placeholder for a well-formed identifier whose post is no longer in + /// the local store (e.g. evicted from the cache), so a saved shortcut + /// keeps working; opening it falls back to a remote fetch. + init?(identifier: String) { + guard AbstractPost.AppIntentIdentifier(identifier: identifier) != nil else { + return nil + } + self.id = identifier + self.title = String( + localized: LocalizedStringResource("ios-appintents.postEntity.placeholderTitle", defaultValue: "Post") + ) + self.siteName = nil + self.isPage = false + } +} + +@available(iOS 18, *) +extension PostEntity: IndexedEntity {} diff --git a/Sources/Jetpack/AppIntents/PostEntityQuery.swift b/Sources/Jetpack/AppIntents/PostEntityQuery.swift new file mode 100644 index 000000000000..10981e3ac8a7 --- /dev/null +++ b/Sources/Jetpack/AppIntents/PostEntityQuery.swift @@ -0,0 +1,35 @@ +import AppIntents +import Foundation +import WordPressData + +/// Resolves and searches `PostEntity` values from the local Core Data store. +struct PostEntityQuery: EntityStringQuery { + @MainActor + func entities(for identifiers: [PostEntity.ID]) async throws -> [PostEntity] { + let context = ContextManager.shared.mainContext + return identifiers.compactMap { identifier in + if let post = AbstractPost.forAppIntent(identifier: identifier, in: context), + let entity = PostEntity(post: post) + { + return entity + } + // A well-formed identifier missing from the local store still + // resolves to a placeholder, so a saved shortcut keeps working + // after the post is evicted from the cache; opening it falls + // back to a remote fetch. + return PostEntity(identifier: identifier) + } + } + + @MainActor + func entities(matching string: String) async throws -> [PostEntity] { + let context = ContextManager.shared.mainContext + return AbstractPost.searchForAppIntent(matching: string, in: context).compactMap { PostEntity(post: $0) } + } + + @MainActor + func suggestedEntities() async throws -> [PostEntity] { + let context = ContextManager.shared.mainContext + return AbstractPost.searchForAppIntent(matching: "", limit: 10, in: context).compactMap { PostEntity(post: $0) } + } +} diff --git a/Sources/Jetpack/AppIntents/ReaderPostEntity.swift b/Sources/Jetpack/AppIntents/ReaderPostEntity.swift new file mode 100644 index 000000000000..a651733bdcc7 --- /dev/null +++ b/Sources/Jetpack/AppIntents/ReaderPostEntity.swift @@ -0,0 +1,56 @@ +import AppIntents +import Foundation +import WordPressData + +/// A post from the user's Reader, as exposed to the system via App Intents. +/// +/// The identifier is the same composite string the Spotlight index uses, so a Spotlight +/// item and its associated app entity always name the same post. +struct ReaderPostEntity: AppEntity { + static let typeDisplayRepresentation = TypeDisplayRepresentation( + name: LocalizedStringResource("ios-appintents.readerPostEntity.typeName", defaultValue: "Reader Post") + ) + + static var defaultQuery: ReaderPostEntityQuery { ReaderPostEntityQuery() } + + let id: String + let title: String + let blogName: String? + + var displayRepresentation: DisplayRepresentation { + guard let blogName else { + return DisplayRepresentation(title: "\(title)") + } + return DisplayRepresentation(title: "\(title)", subtitle: "\(blogName)") + } + + /// Fails for posts missing the site or post ID needed for a stable identifier. + init?(post: ReaderPost) { + guard let identifier = post.uniqueIdentifier else { + return nil + } + self.id = identifier + self.title = post.titleForDisplay() + self.blogName = post.blogNameForDisplay() + } + + /// A placeholder for a well-formed identifier whose post is no longer in + /// the local store (Reader rows are purged routinely), so a saved + /// shortcut keeps working; opening it navigates by the IDs alone. + init?(identifier: String) { + guard ReaderPost.AppIntentIdentifier(identifier: identifier) != nil else { + return nil + } + self.id = identifier + self.title = String( + localized: LocalizedStringResource( + "ios-appintents.readerPostEntity.placeholderTitle", + defaultValue: "Reader Post" + ) + ) + self.blogName = nil + } +} + +@available(iOS 18, *) +extension ReaderPostEntity: IndexedEntity {} diff --git a/Sources/Jetpack/AppIntents/ReaderPostEntityQuery.swift b/Sources/Jetpack/AppIntents/ReaderPostEntityQuery.swift new file mode 100644 index 000000000000..e167a5d930ab --- /dev/null +++ b/Sources/Jetpack/AppIntents/ReaderPostEntityQuery.swift @@ -0,0 +1,38 @@ +import AppIntents +import Foundation +import WordPressData + +/// Resolves and searches `ReaderPostEntity` values from the local Core Data store. +struct ReaderPostEntityQuery: EntityStringQuery { + @MainActor + func entities(for identifiers: [ReaderPostEntity.ID]) async throws -> [ReaderPostEntity] { + let context = ContextManager.shared.mainContext + return identifiers.compactMap { identifier in + if let post = ReaderPost.forAppIntent(identifier: identifier, in: context), + let entity = ReaderPostEntity(post: post) + { + return entity + } + // A well-formed identifier missing from the local store still + // resolves to a placeholder, so a saved shortcut keeps working + // after the Reader cache purges the post; opening it navigates + // by the IDs alone. + return ReaderPostEntity(identifier: identifier) + } + } + + @MainActor + func entities(matching string: String) async throws -> [ReaderPostEntity] { + let context = ContextManager.shared.mainContext + return ReaderPost.searchForAppIntent(matching: string, in: context).compactMap { ReaderPostEntity(post: $0) } + } + + @MainActor + func suggestedEntities() async throws -> [ReaderPostEntity] { + let context = ContextManager.shared.mainContext + return ReaderPost.searchForAppIntent(matching: "", limit: 10, in: context) + .compactMap { + ReaderPostEntity(post: $0) + } + } +} diff --git a/Sources/Jetpack/AppIntents/SearchManager+EntityAssociation.swift b/Sources/Jetpack/AppIntents/SearchManager+EntityAssociation.swift new file mode 100644 index 000000000000..92abf8779cac --- /dev/null +++ b/Sources/Jetpack/AppIntents/SearchManager+EntityAssociation.swift @@ -0,0 +1,26 @@ +import CoreSpotlight +import Foundation +import WordPressData + +// The Jetpack-app-only side of the Spotlight entity association seam declared +// in SearchManager.swift. This file compiles only into the Jetpack app target, +// which is the one that exposes App Intents entities. +extension SearchManager: SearchableItemEntityAssociating { + func associateAppEntities(from item: SearchableItemConvertable, to searchableItem: CSSearchableItem) { + guard #available(iOS 18, *) else { + return + } + switch item { + case let post as AbstractPost: + if let entity = PostEntity(post: post) { + searchableItem.associateAppEntity(entity, priority: 0) + } + case let post as ReaderPost: + if let entity = ReaderPostEntity(post: post) { + searchableItem.associateAppEntity(entity, priority: 0) + } + default: + break + } + } +} diff --git a/WordPress/JetpackAppIntents/en.lproj/Localizable.strings b/WordPress/JetpackAppIntents/en.lproj/Localizable.strings index fc09d73c9c87..cc8dcb97c82e 100644 --- a/WordPress/JetpackAppIntents/en.lproj/Localizable.strings +++ b/WordPress/JetpackAppIntents/en.lproj/Localizable.strings @@ -46,8 +46,44 @@ /* Short title of the Reader shortcut tile in Spotlight and the Shortcuts app. */ "openReader.shortTitle" = "Reader"; +/* Title of the App Intent that opens one of the user's posts or pages. Shown in the Shortcuts app and Spotlight. */ +"openPost.title" = "Open Post"; + +/* Description of the App Intent that opens one of the user's posts or pages. Shown in the Shortcuts app. */ +"openPost.description" = "Opens one of your posts or pages in the app."; + +/* Label of the post parameter of the Open Post App Intent. Shown in the Shortcuts app. */ +"openPost.postParameter" = "Post"; + +/* Title of the App Intent that opens a Reader post. Shown in the Shortcuts app and Spotlight. */ +"openReaderPost.title" = "Open Reader Post"; + +/* Description of the App Intent that opens a Reader post. Shown in the Shortcuts app. */ +"openReaderPost.description" = "Opens a post from your Reader in the app."; + +/* Label of the post parameter of the Open Reader Post App Intent. Shown in the Shortcuts app. */ +"openReaderPost.postParameter" = "Post"; + +/* Type name of the post entity in the Shortcuts app, e.g. shown when picking a post. */ +"postEntity.typeName" = "Post"; + +/* Label shown next to a post's site name in the Shortcuts app when the item is a page rather than a post. */ +"postEntity.pageKind" = "Page"; + +/* Type name of the Reader post entity in the Shortcuts app, e.g. shown when picking a post. */ +"readerPostEntity.typeName" = "Reader Post"; + +/* Generic title shown in the Shortcuts app for a post in a saved shortcut that is no longer cached on this device. */ +"postEntity.placeholderTitle" = "Post"; + +/* Generic title shown in the Shortcuts app for a Reader post in a saved shortcut that is no longer cached on this device. */ +"readerPostEntity.placeholderTitle" = "Reader Post"; + /* Error shown by Siri or the Shortcuts app when an App Intent runs while no account is signed in. */ "openError.notLoggedIn" = "Sign in to the app first to use this action."; /* Error shown by Siri or the Shortcuts app when the site an App Intent should act on no longer exists. */ "openError.siteNotFound" = "The site could not be found."; + +/* 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."; From 600884221ea0f5e8fe84c4f4b5ac8f71b6fc4124 Mon Sep 17 00:00:00 2001 From: Tony Li Date: Tue, 7 Jul 2026 15:59:28 +1200 Subject: [PATCH 5/5] Add a release note for the post entities and open intents --- RELEASE-NOTES.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/RELEASE-NOTES.txt b/RELEASE-NOTES.txt index 2c0712730427..c0e70981ad57 100644 --- a/RELEASE-NOTES.txt +++ b/RELEASE-NOTES.txt @@ -4,6 +4,7 @@ * [*] [build tooling] Add a String Catalog localization pipeline (plurals + build-free catalog generation) with a CI coverage gate [#25688] * [*] [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] 27.0