Skip to content

Commit 4006c42

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 bb7cadd commit 4006c42

8 files changed

Lines changed: 327 additions & 0 deletions

Sources/Jetpack/AppIntents/AppIntentOpenError.swift

Lines changed: 8 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 {
@@ -22,6 +23,13 @@ enum AppIntentOpenError: Error, CustomLocalizedStringResourceConvertible {
2223
comment:
2324
"Error shown by Siri or the Shortcuts app when the site an App Intent should act on no longer exists."
2425
)
26+
case .postNotFound:
27+
return LocalizedStringResource(
28+
"ios-appintents.openError.postNotFound",
29+
defaultValue: "The post could not be found.",
30+
comment:
31+
"Error shown by Siri or the Shortcuts app when the post an App Intent should act on no longer exists."
32+
)
2533
}
2634
}
2735
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
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(
7+
"ios-appintents.openPost.title",
8+
defaultValue: "Open Post",
9+
comment:
10+
"Title of the App Intent that opens one of the user's posts or pages. Shown in the Shortcuts app and Spotlight."
11+
)
12+
static let description = IntentDescription(
13+
LocalizedStringResource(
14+
"ios-appintents.openPost.description",
15+
defaultValue: "Opens one of your posts or pages in the app.",
16+
comment:
17+
"Description of the App Intent that opens one of the user's posts or pages. Shown in the Shortcuts app."
18+
)
19+
)
20+
static let openAppWhenRun = true
21+
22+
@Parameter(
23+
title: LocalizedStringResource(
24+
"ios-appintents.openPost.postParameter",
25+
defaultValue: "Post",
26+
comment: "Label of the post parameter of the Open Post App Intent. Shown in the Shortcuts app."
27+
)
28+
)
29+
var post: PostEntity
30+
31+
@MainActor
32+
func perform() async throws -> some IntentResult {
33+
guard AccountHelper.isLoggedIn else {
34+
throw AppIntentOpenError.notLoggedIn
35+
}
36+
guard await SearchManager.shared.openItem(withUniqueIdentifier: post.id, source: .appIntent) else {
37+
throw AppIntentOpenError.postNotFound
38+
}
39+
return .result()
40+
}
41+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
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(
7+
"ios-appintents.openReaderPost.title",
8+
defaultValue: "Open Reader Post",
9+
comment: "Title of the App Intent that opens a Reader post. Shown in the Shortcuts app and Spotlight."
10+
)
11+
static let description = IntentDescription(
12+
LocalizedStringResource(
13+
"ios-appintents.openReaderPost.description",
14+
defaultValue: "Opens a post from your Reader in the app.",
15+
comment: "Description of the App Intent that opens a Reader post. Shown in the Shortcuts app."
16+
)
17+
)
18+
static let openAppWhenRun = true
19+
20+
@Parameter(
21+
title: LocalizedStringResource(
22+
"ios-appintents.openReaderPost.postParameter",
23+
defaultValue: "Post",
24+
comment: "Label of the post parameter of the Open Reader Post App Intent. Shown in the Shortcuts app."
25+
)
26+
)
27+
var post: ReaderPostEntity
28+
29+
@MainActor
30+
func perform() async throws -> some IntentResult {
31+
guard AccountHelper.isLoggedIn else {
32+
throw AppIntentOpenError.notLoggedIn
33+
}
34+
guard await SearchManager.shared.openItem(withUniqueIdentifier: post.id, source: .appIntent) else {
35+
throw AppIntentOpenError.postNotFound
36+
}
37+
return .result()
38+
}
39+
}
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
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(
12+
"ios-appintents.postEntity.typeName",
13+
defaultValue: "Post",
14+
comment: "Type name of the post entity in the Shortcuts app, e.g. shown when picking a post."
15+
)
16+
)
17+
18+
static var defaultQuery: PostEntityQuery { PostEntityQuery() }
19+
20+
let id: String
21+
let title: String
22+
let siteName: String?
23+
let isPage: Bool
24+
25+
var displayRepresentation: DisplayRepresentation {
26+
let kind =
27+
isPage
28+
? String(
29+
localized: LocalizedStringResource(
30+
"ios-appintents.postEntity.pageKind",
31+
defaultValue: "Page",
32+
comment:
33+
"Label shown next to a post's site name in the Shortcuts app when the item is a page rather than a post."
34+
)
35+
)
36+
: nil
37+
let subtitle = [kind, siteName].compactMap { $0 }.joined(separator: " · ")
38+
guard !subtitle.isEmpty else {
39+
return DisplayRepresentation(title: "\(title)")
40+
}
41+
return DisplayRepresentation(title: "\(title)", subtitle: "\(subtitle)")
42+
}
43+
44+
/// Fails for posts that only exist locally: without a remote post ID there
45+
/// is no stable identifier to expose.
46+
init?(post: AbstractPost) {
47+
guard let identifier = post.uniqueIdentifier else {
48+
return nil
49+
}
50+
self.id = identifier
51+
self.title = post.titleForDisplay()
52+
self.siteName = post.blog.settings?.name ?? post.blog.displayURL as String?
53+
self.isPage = post is Page
54+
}
55+
56+
/// A placeholder for a well-formed identifier whose post is no longer in
57+
/// the local store (e.g. evicted from the cache), so a saved shortcut
58+
/// keeps working; opening it falls back to a remote fetch.
59+
init?(identifier: String) {
60+
guard AbstractPost.AppIntentIdentifier(identifier: identifier) != nil else {
61+
return nil
62+
}
63+
self.id = identifier
64+
self.title = String(
65+
localized: LocalizedStringResource(
66+
"ios-appintents.postEntity.placeholderTitle",
67+
defaultValue: "Post",
68+
comment:
69+
"Generic title shown in the Shortcuts app for a post in a saved shortcut that is no longer cached on this device."
70+
)
71+
)
72+
self.siteName = nil
73+
self.isPage = false
74+
}
75+
}
76+
77+
@available(iOS 18, *)
78+
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: 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 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(
12+
"ios-appintents.readerPostEntity.typeName",
13+
defaultValue: "Reader Post",
14+
comment: "Type name of the Reader post entity in the Shortcuts app, e.g. shown when picking a post."
15+
)
16+
)
17+
18+
static var defaultQuery: ReaderPostEntityQuery { ReaderPostEntityQuery() }
19+
20+
let id: String
21+
let title: String
22+
let blogName: String?
23+
24+
var displayRepresentation: DisplayRepresentation {
25+
guard let blogName else {
26+
return DisplayRepresentation(title: "\(title)")
27+
}
28+
return DisplayRepresentation(title: "\(title)", subtitle: "\(blogName)")
29+
}
30+
31+
/// Fails for posts missing the site or post ID needed for a stable identifier.
32+
init?(post: ReaderPost) {
33+
guard let identifier = post.uniqueIdentifier else {
34+
return nil
35+
}
36+
self.id = identifier
37+
self.title = post.titleForDisplay()
38+
self.blogName = post.blogNameForDisplay()
39+
}
40+
41+
/// A placeholder for a well-formed identifier whose post is no longer in
42+
/// the local store (Reader rows are purged routinely), so a saved
43+
/// shortcut keeps working; opening it navigates by the IDs alone.
44+
init?(identifier: String) {
45+
guard ReaderPost.AppIntentIdentifier(identifier: identifier) != nil else {
46+
return nil
47+
}
48+
self.id = identifier
49+
self.title = String(
50+
localized: LocalizedStringResource(
51+
"ios-appintents.readerPostEntity.placeholderTitle",
52+
defaultValue: "Reader Post",
53+
comment:
54+
"Generic title shown in the Shortcuts app for a Reader post in a saved shortcut that is no longer cached on this device."
55+
)
56+
)
57+
self.blogName = nil
58+
}
59+
}
60+
61+
@available(iOS 18, *)
62+
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+
}

0 commit comments

Comments
 (0)