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
6 changes: 6 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 @@ -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."
)
}
}
}
28 changes: 28 additions & 0 deletions Sources/Jetpack/AppIntents/OpenPostIntent.swift
Original file line number Diff line number Diff line change
@@ -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()
}
}
28 changes: 28 additions & 0 deletions Sources/Jetpack/AppIntents/OpenReaderPostIntent.swift
Original file line number Diff line number Diff line change
@@ -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()
}
}
62 changes: 62 additions & 0 deletions Sources/Jetpack/AppIntents/PostEntity.swift
Original file line number Diff line number Diff line change
@@ -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 {}
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) }
}
}
56 changes: 56 additions & 0 deletions Sources/Jetpack/AppIntents/ReaderPostEntity.swift
Original file line number Diff line number Diff line change
@@ -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 {}
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