diff --git a/Modules/Sources/WordPressData/Swift/SearchIdentifierGenerator.swift b/Modules/Sources/WordPressData/Swift/SearchIdentifierGenerator.swift index 021ecc09917d..37eec4d5847c 100644 --- a/Modules/Sources/WordPressData/Swift/SearchIdentifierGenerator.swift +++ b/Modules/Sources/WordPressData/Swift/SearchIdentifierGenerator.swift @@ -3,13 +3,33 @@ 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]) } + + /// 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/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 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/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 + } + } +} diff --git a/WordPress/Classes/Utility/Spotlight/SearchManager.swift b/WordPress/Classes/Utility/Spotlight/SearchManager.swift index 979f7cd18c6b..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,17 +54,28 @@ 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 } - 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 +100,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 +131,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 +151,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 +181,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 +193,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,79 +220,127 @@ 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, + 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)") + DDLogError( + "Search manager unable to parse postID/siteID for identifier:\(identifier) domain:\(domainString)" + ) 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 { - 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)") - }) + let readerPostID = NumberFormatter().number(from: identifier) + else { + DDLogError( + "Search manager unable to parse postID/siteID for identifier:\(identifier) domain:\(domainString)" + ) + return false + } + 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 { 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,59 +350,53 @@ 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) { + @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 + return nil } + return await fetchPost(postID, for: blog, using: coreDataStack) + } - 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() - } + @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 { + return nil } + return await fetchPost(postID, for: blog, using: coreDataStack) } - 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 { - onFailure() - return + @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 } } - 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 +406,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() @@ -365,39 +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) - }) + openReader( + for: post, + onFailure: { + // If opening the reader fails, just open preview. + 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) - }) + openReader( + for: page, + onFailure: { + // If opening the reader fails, just open preview. + openPreview(for: page, source: source) + } + ) } else if pageIsPublishedOrScheduled { - openPreview(for: page) + openPreview(for: page, source: source) } else { openEditor(for: page) } @@ -416,9 +529,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)) } @@ -452,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 @@ -478,9 +592,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 +606,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) } 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.";