Skip to content

Commit c57bd25

Browse files
committed
Add post and reader post App Intents entities to the Jetpack app
PostEntity covers posts and pages, while ReaderPostEntity exposes Reader results through Shortcuts and Spotlight. Both reuse the Spotlight composite identifier, keep saved shortcuts resolvable when local caches are missing, and route opens through the existing navigation paths. The existing Page translation is reused where its noun context matches; the remaining new strings use English defaults.
1 parent 0603d22 commit c57bd25

9 files changed

Lines changed: 460 additions & 0 deletions

Sources/Jetpack/AppIntents/AppIntentOpenError.swift

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