Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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])
}
}
1 change: 1 addition & 0 deletions RELEASE-NOTES.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions Sources/Jetpack/AppIntents/AppIntentOpenError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import Foundation
enum AppIntentOpenError: Error, CustomLocalizedStringResourceConvertible {
case notLoggedIn
case siteNotFound
case postNotFound

var localizedStringResource: LocalizedStringResource {
switch self {
Expand All @@ -22,6 +23,13 @@ enum AppIntentOpenError: Error, CustomLocalizedStringResourceConvertible {
comment:
"Error shown by Siri or the Shortcuts app when the site an App Intent should act on no longer exists."
)
case .postNotFound:
return LocalizedStringResource(
"ios-appintents.openError.postNotFound",
defaultValue: "The post could not be found.",
comment:
"Error shown by Siri or the Shortcuts app when the post an App Intent should act on no longer exists."
)
}
}
}
41 changes: 41 additions & 0 deletions Sources/Jetpack/AppIntents/OpenPostIntent.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
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",
comment:
"Title of the App Intent that opens one of the user's posts or pages. Shown in the Shortcuts app and Spotlight."
)
static let description = IntentDescription(
LocalizedStringResource(
"ios-appintents.openPost.description",
defaultValue: "Opens one of your posts or pages in the app.",
comment:
"Description of the App Intent that opens one of the user's posts or pages. Shown in the Shortcuts app."
)
)
static let openAppWhenRun = true

@Parameter(
title: LocalizedStringResource(
"ios-appintents.openPost.postParameter",
defaultValue: "Post",
comment: "Label of the post parameter of the Open Post App Intent. Shown in the Shortcuts app."
)
)
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()
}
}
39 changes: 39 additions & 0 deletions Sources/Jetpack/AppIntents/OpenReaderPostIntent.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
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",
comment: "Title of the App Intent that opens a Reader post. Shown in the Shortcuts app and Spotlight."
)
static let description = IntentDescription(
LocalizedStringResource(
"ios-appintents.openReaderPost.description",
defaultValue: "Opens a post from your Reader in the app.",
comment: "Description of the App Intent that opens a Reader post. Shown in the Shortcuts app."
)
)
static let openAppWhenRun = true

@Parameter(
title: LocalizedStringResource(
"ios-appintents.openReaderPost.postParameter",
defaultValue: "Post",
comment: "Label of the post parameter of the Open Reader Post App Intent. Shown in the Shortcuts app."
)
)
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()
}
}
78 changes: 78 additions & 0 deletions Sources/Jetpack/AppIntents/PostEntity.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
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",
comment: "Type name of the post entity in the Shortcuts app, e.g. shown when picking a 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",
comment:
"Label shown next to a post's site name in the Shortcuts app when the item is a page rather than a post."
)
)
: 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",
comment:
"Generic title shown in the Shortcuts app for a post in a saved shortcut that is no longer cached on this device."
)
)
self.siteName = nil
self.isPage = false
}
}

@available(iOS 18, *)
extension PostEntity: IndexedEntity {}
35 changes: 35 additions & 0 deletions Sources/Jetpack/AppIntents/PostEntityQuery.swift
Original file line number Diff line number Diff line change
@@ -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) }
}
}
62 changes: 62 additions & 0 deletions Sources/Jetpack/AppIntents/ReaderPostEntity.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
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",
comment: "Type name of the Reader post entity in the Shortcuts app, e.g. shown when picking a 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",
comment:
"Generic title shown in the Shortcuts app for a Reader post in a saved shortcut that is no longer cached on this device."
)
)
self.blogName = nil
}
}

@available(iOS 18, *)
extension ReaderPostEntity: IndexedEntity {}
38 changes: 38 additions & 0 deletions Sources/Jetpack/AppIntents/ReaderPostEntityQuery.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
26 changes: 26 additions & 0 deletions Sources/Jetpack/AppIntents/SearchManager+EntityAssociation.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
}
Loading