Skip to content

Commit 447a9ee

Browse files
committed
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.
1 parent 80b7a0a commit 447a9ee

9 files changed

Lines changed: 315 additions & 0 deletions

File tree

Sources/Jetpack/AppIntents/AppIntentOpenError.swift

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import Foundation
55
enum AppIntentOpenError: Error, CustomLocalizedStringResourceConvertible {
66
case notLoggedIn
77
case siteNotFound
8+
case postNotFound
89

910
var localizedStringResource: LocalizedStringResource {
1011
switch self {
@@ -18,6 +19,11 @@ enum AppIntentOpenError: Error, CustomLocalizedStringResourceConvertible {
1819
"ios-appintents.openError.siteNotFound",
1920
defaultValue: "The site could not be found."
2021
)
22+
case .postNotFound:
23+
return LocalizedStringResource(
24+
"ios-appintents.openError.postNotFound",
25+
defaultValue: "The post could not be found."
26+
)
2127
}
2228
}
2329
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import AppIntents
2+
import Foundation
3+
4+
/// Opens a post or page in the app, with the same routing as tapping it in Spotlight.
5+
struct OpenPostIntent: AppIntent {
6+
static let title = LocalizedStringResource("ios-appintents.openPost.title", defaultValue: "Open Post")
7+
static let description = IntentDescription(
8+
LocalizedStringResource(
9+
"ios-appintents.openPost.description",
10+
defaultValue: "Opens one of your posts or pages in the app."
11+
)
12+
)
13+
static let openAppWhenRun = true
14+
15+
@Parameter(title: LocalizedStringResource("ios-appintents.openPost.postParameter", defaultValue: "Post"))
16+
var post: PostEntity
17+
18+
@MainActor
19+
func perform() async throws -> some IntentResult {
20+
guard AccountHelper.isLoggedIn else {
21+
throw AppIntentOpenError.notLoggedIn
22+
}
23+
guard await SearchManager.shared.openItem(withUniqueIdentifier: post.id, source: .appIntent) else {
24+
throw AppIntentOpenError.postNotFound
25+
}
26+
return .result()
27+
}
28+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import AppIntents
2+
import Foundation
3+
4+
/// Opens a Reader post in the app, with the same routing as tapping it in Spotlight.
5+
struct OpenReaderPostIntent: AppIntent {
6+
static let title = LocalizedStringResource("ios-appintents.openReaderPost.title", defaultValue: "Open Reader Post")
7+
static let description = IntentDescription(
8+
LocalizedStringResource(
9+
"ios-appintents.openReaderPost.description",
10+
defaultValue: "Opens a post from your Reader in the app."
11+
)
12+
)
13+
static let openAppWhenRun = true
14+
15+
@Parameter(title: LocalizedStringResource("ios-appintents.openReaderPost.postParameter", defaultValue: "Post"))
16+
var post: ReaderPostEntity
17+
18+
@MainActor
19+
func perform() async throws -> some IntentResult {
20+
guard AccountHelper.isLoggedIn else {
21+
throw AppIntentOpenError.notLoggedIn
22+
}
23+
guard await SearchManager.shared.openItem(withUniqueIdentifier: post.id, source: .appIntent) else {
24+
throw AppIntentOpenError.postNotFound
25+
}
26+
return .result()
27+
}
28+
}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import AppIntents
2+
import Foundation
3+
import WordPressData
4+
5+
/// A post or page on one of the user's sites, as exposed to the system via App Intents.
6+
///
7+
/// The identifier is the same composite string the Spotlight index uses, so a Spotlight
8+
/// item and its associated app entity always name the same post.
9+
struct PostEntity: AppEntity {
10+
static let typeDisplayRepresentation = TypeDisplayRepresentation(
11+
name: LocalizedStringResource("ios-appintents.postEntity.typeName", defaultValue: "Post")
12+
)
13+
14+
static var defaultQuery: PostEntityQuery { PostEntityQuery() }
15+
16+
let id: String
17+
let title: String
18+
let siteName: String?
19+
let isPage: Bool
20+
21+
var displayRepresentation: DisplayRepresentation {
22+
let kind =
23+
isPage
24+
? String(localized: LocalizedStringResource("ios-appintents.postEntity.pageKind", defaultValue: "Page"))
25+
: nil
26+
let subtitle = [kind, siteName].compactMap { $0 }.joined(separator: " · ")
27+
guard !subtitle.isEmpty else {
28+
return DisplayRepresentation(title: "\(title)")
29+
}
30+
return DisplayRepresentation(title: "\(title)", subtitle: "\(subtitle)")
31+
}
32+
33+
/// Fails for posts that only exist locally: without a remote post ID there
34+
/// is no stable identifier to expose.
35+
init?(post: AbstractPost) {
36+
guard let identifier = post.uniqueIdentifier else {
37+
return nil
38+
}
39+
self.id = identifier
40+
self.title = post.titleForDisplay()
41+
self.siteName = post.blog.settings?.name ?? post.blog.displayURL as String?
42+
self.isPage = post is Page
43+
}
44+
45+
/// A placeholder for a well-formed identifier whose post is no longer in
46+
/// the local store (e.g. evicted from the cache), so a saved shortcut
47+
/// keeps working; opening it falls back to a remote fetch.
48+
init?(identifier: String) {
49+
guard AbstractPost.AppIntentIdentifier(identifier: identifier) != nil else {
50+
return nil
51+
}
52+
self.id = identifier
53+
self.title = String(
54+
localized: LocalizedStringResource("ios-appintents.postEntity.placeholderTitle", defaultValue: "Post")
55+
)
56+
self.siteName = nil
57+
self.isPage = false
58+
}
59+
}
60+
61+
@available(iOS 18, *)
62+
extension PostEntity: IndexedEntity {}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import AppIntents
2+
import Foundation
3+
import WordPressData
4+
5+
/// Resolves and searches `PostEntity` values from the local Core Data store.
6+
struct PostEntityQuery: EntityStringQuery {
7+
@MainActor
8+
func entities(for identifiers: [PostEntity.ID]) async throws -> [PostEntity] {
9+
let context = ContextManager.shared.mainContext
10+
return identifiers.compactMap { identifier in
11+
if let post = AbstractPost.forAppIntent(identifier: identifier, in: context),
12+
let entity = PostEntity(post: post)
13+
{
14+
return entity
15+
}
16+
// A well-formed identifier missing from the local store still
17+
// resolves to a placeholder, so a saved shortcut keeps working
18+
// after the post is evicted from the cache; opening it falls
19+
// back to a remote fetch.
20+
return PostEntity(identifier: identifier)
21+
}
22+
}
23+
24+
@MainActor
25+
func entities(matching string: String) async throws -> [PostEntity] {
26+
let context = ContextManager.shared.mainContext
27+
return AbstractPost.searchForAppIntent(matching: string, in: context).compactMap { PostEntity(post: $0) }
28+
}
29+
30+
@MainActor
31+
func suggestedEntities() async throws -> [PostEntity] {
32+
let context = ContextManager.shared.mainContext
33+
return AbstractPost.searchForAppIntent(matching: "", limit: 10, in: context).compactMap { PostEntity(post: $0) }
34+
}
35+
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import AppIntents
2+
import Foundation
3+
import WordPressData
4+
5+
/// A post from the user's Reader, as exposed to the system via App Intents.
6+
///
7+
/// The identifier is the same composite string the Spotlight index uses, so a Spotlight
8+
/// item and its associated app entity always name the same post.
9+
struct ReaderPostEntity: AppEntity {
10+
static let typeDisplayRepresentation = TypeDisplayRepresentation(
11+
name: LocalizedStringResource("ios-appintents.readerPostEntity.typeName", defaultValue: "Reader Post")
12+
)
13+
14+
static var defaultQuery: ReaderPostEntityQuery { ReaderPostEntityQuery() }
15+
16+
let id: String
17+
let title: String
18+
let blogName: String?
19+
20+
var displayRepresentation: DisplayRepresentation {
21+
guard let blogName else {
22+
return DisplayRepresentation(title: "\(title)")
23+
}
24+
return DisplayRepresentation(title: "\(title)", subtitle: "\(blogName)")
25+
}
26+
27+
/// Fails for posts missing the site or post ID needed for a stable identifier.
28+
init?(post: ReaderPost) {
29+
guard let identifier = post.uniqueIdentifier else {
30+
return nil
31+
}
32+
self.id = identifier
33+
self.title = post.titleForDisplay()
34+
self.blogName = post.blogNameForDisplay()
35+
}
36+
37+
/// A placeholder for a well-formed identifier whose post is no longer in
38+
/// the local store (Reader rows are purged routinely), so a saved
39+
/// shortcut keeps working; opening it navigates by the IDs alone.
40+
init?(identifier: String) {
41+
guard ReaderPost.AppIntentIdentifier(identifier: identifier) != nil else {
42+
return nil
43+
}
44+
self.id = identifier
45+
self.title = String(
46+
localized: LocalizedStringResource(
47+
"ios-appintents.readerPostEntity.placeholderTitle",
48+
defaultValue: "Reader Post"
49+
)
50+
)
51+
self.blogName = nil
52+
}
53+
}
54+
55+
@available(iOS 18, *)
56+
extension ReaderPostEntity: IndexedEntity {}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import AppIntents
2+
import Foundation
3+
import WordPressData
4+
5+
/// Resolves and searches `ReaderPostEntity` values from the local Core Data store.
6+
struct ReaderPostEntityQuery: EntityStringQuery {
7+
@MainActor
8+
func entities(for identifiers: [ReaderPostEntity.ID]) async throws -> [ReaderPostEntity] {
9+
let context = ContextManager.shared.mainContext
10+
return identifiers.compactMap { identifier in
11+
if let post = ReaderPost.forAppIntent(identifier: identifier, in: context),
12+
let entity = ReaderPostEntity(post: post)
13+
{
14+
return entity
15+
}
16+
// A well-formed identifier missing from the local store still
17+
// resolves to a placeholder, so a saved shortcut keeps working
18+
// after the Reader cache purges the post; opening it navigates
19+
// by the IDs alone.
20+
return ReaderPostEntity(identifier: identifier)
21+
}
22+
}
23+
24+
@MainActor
25+
func entities(matching string: String) async throws -> [ReaderPostEntity] {
26+
let context = ContextManager.shared.mainContext
27+
return ReaderPost.searchForAppIntent(matching: string, in: context).compactMap { ReaderPostEntity(post: $0) }
28+
}
29+
30+
@MainActor
31+
func suggestedEntities() async throws -> [ReaderPostEntity] {
32+
let context = ContextManager.shared.mainContext
33+
return ReaderPost.searchForAppIntent(matching: "", limit: 10, in: context)
34+
.compactMap {
35+
ReaderPostEntity(post: $0)
36+
}
37+
}
38+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import CoreSpotlight
2+
import Foundation
3+
import WordPressData
4+
5+
// The Jetpack-app-only side of the Spotlight entity association seam declared
6+
// in SearchManager.swift. This file compiles only into the Jetpack app target,
7+
// which is the one that exposes App Intents entities.
8+
extension SearchManager: SearchableItemEntityAssociating {
9+
func associateAppEntities(from item: SearchableItemConvertable, to searchableItem: CSSearchableItem) {
10+
guard #available(iOS 18, *) else {
11+
return
12+
}
13+
switch item {
14+
case let post as AbstractPost:
15+
if let entity = PostEntity(post: post) {
16+
searchableItem.associateAppEntity(entity, priority: 0)
17+
}
18+
case let post as ReaderPost:
19+
if let entity = ReaderPostEntity(post: post) {
20+
searchableItem.associateAppEntity(entity, priority: 0)
21+
}
22+
default:
23+
break
24+
}
25+
}
26+
}

WordPress/JetpackAppIntents/en.lproj/Localizable.strings

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,44 @@
4646
/* Short title of the Reader shortcut tile in Spotlight and the Shortcuts app. */
4747
"openReader.shortTitle" = "Reader";
4848

49+
/* Title of the App Intent that opens one of the user's posts or pages. Shown in the Shortcuts app and Spotlight. */
50+
"openPost.title" = "Open Post";
51+
52+
/* Description of the App Intent that opens one of the user's posts or pages. Shown in the Shortcuts app. */
53+
"openPost.description" = "Opens one of your posts or pages in the app.";
54+
55+
/* Label of the post parameter of the Open Post App Intent. Shown in the Shortcuts app. */
56+
"openPost.postParameter" = "Post";
57+
58+
/* Title of the App Intent that opens a Reader post. Shown in the Shortcuts app and Spotlight. */
59+
"openReaderPost.title" = "Open Reader Post";
60+
61+
/* Description of the App Intent that opens a Reader post. Shown in the Shortcuts app. */
62+
"openReaderPost.description" = "Opens a post from your Reader in the app.";
63+
64+
/* Label of the post parameter of the Open Reader Post App Intent. Shown in the Shortcuts app. */
65+
"openReaderPost.postParameter" = "Post";
66+
67+
/* Type name of the post entity in the Shortcuts app, e.g. shown when picking a post. */
68+
"postEntity.typeName" = "Post";
69+
70+
/* Label shown next to a post's site name in the Shortcuts app when the item is a page rather than a post. */
71+
"postEntity.pageKind" = "Page";
72+
73+
/* Type name of the Reader post entity in the Shortcuts app, e.g. shown when picking a post. */
74+
"readerPostEntity.typeName" = "Reader Post";
75+
76+
/* Generic title shown in the Shortcuts app for a post in a saved shortcut that is no longer cached on this device. */
77+
"postEntity.placeholderTitle" = "Post";
78+
79+
/* Generic title shown in the Shortcuts app for a Reader post in a saved shortcut that is no longer cached on this device. */
80+
"readerPostEntity.placeholderTitle" = "Reader Post";
81+
4982
/* Error shown by Siri or the Shortcuts app when an App Intent runs while no account is signed in. */
5083
"openError.notLoggedIn" = "Sign in to the app first to use this action.";
5184

5285
/* Error shown by Siri or the Shortcuts app when the site an App Intent should act on no longer exists. */
5386
"openError.siteNotFound" = "The site could not be found.";
87+
88+
/* Error shown by Siri or the Shortcuts app when the post an App Intent should act on no longer exists. */
89+
"openError.postNotFound" = "The post could not be found.";

0 commit comments

Comments
 (0)