feat(intents): add App Intents + Siri integration
All checks were successful
CI / build-and-deploy (push) Successful in 24s
All checks were successful
CI / build-and-deploy (push) Successful in 24s
Expose Marks to Siri, Spotlight, and Shortcuts via App Intents: - BookmarkEntity (AppEntity + IndexedEntity, keyed on the Linkding server id) with a server-backed EntityStringQuery for live lookup - Intents: Add, Open (OpenIntent), Search, ShowUnread, and an AI Summarize intent backed by ClaudeService; AppShortcutsProvider with spoken phrases; Siri snippet views and dialog - SearchMarksIntent conforms to the system.search App Schema (@AppIntent(schema:), ShowInAppSearchResultsIntent) so it is executable by conversational Siri / Apple Intelligence - IntentRouter bridges intents (run in the app process) to the SwiftUI scene for open/search navigation; widgets refreshed after adds - Link AppIntents.framework so the metadata processor extracts shortcut phrases and validates the assistant schema Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
177
Marks/Intents/MarksAppIntents.swift
Normal file
177
Marks/Intents/MarksAppIntents.swift
Normal file
@@ -0,0 +1,177 @@
|
||||
import Foundation
|
||||
import AppIntents
|
||||
|
||||
// MARK: - Open
|
||||
|
||||
/// Opens a bookmark in the in-app browser. Conforms to `OpenIntent` — the
|
||||
/// system-blessed "open" type Siri understands. (iOS 26 has no separate
|
||||
/// `system.open` App Schema; `OpenIntent` with an `AppEntity` target *is* the
|
||||
/// supported mechanism, so no schema macro is needed here.)
|
||||
struct OpenBookmarkIntent: OpenIntent {
|
||||
static let title: LocalizedStringResource = "Open Bookmark"
|
||||
static let openAppWhenRun = true
|
||||
|
||||
@Parameter(title: "Bookmark")
|
||||
var target: BookmarkEntity
|
||||
|
||||
@MainActor
|
||||
func perform() async throws -> some IntentResult {
|
||||
IntentRouter.shared.openBookmark(url: target.url.absoluteString)
|
||||
return .result()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Search
|
||||
|
||||
/// Searches Marks and shows the results in the app's search tab.
|
||||
///
|
||||
/// Conforms to the formal `system.search` App Schema (`ShowInAppSearchResultsIntent`),
|
||||
/// which is what makes it executable by conversational Siri / Apple Intelligence:
|
||||
/// Siri hands over the spoken term as `criteria` and the app re-runs it in its
|
||||
/// own search UI. `searchScopes` defaults to `[.general]` for string criteria.
|
||||
@AppIntent(schema: .system.search)
|
||||
struct SearchMarksIntent {
|
||||
static let title: LocalizedStringResource = "Search Marks"
|
||||
static let description = IntentDescription("Search your Marks bookmarks.")
|
||||
|
||||
@Parameter(title: "Search", requestValueDialog: "What do you want to search for?")
|
||||
var criteria: StringSearchCriteria
|
||||
|
||||
@MainActor
|
||||
func perform() async throws -> some IntentResult {
|
||||
IntentRouter.shared.search(criteria.term)
|
||||
return .result()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Add
|
||||
|
||||
/// Saves a link to Marks without opening the app. Mirrors the Share Extension's
|
||||
/// headless save, but goes through the typed `LinkdingAPI`.
|
||||
struct AddBookmarkIntent: AppIntent {
|
||||
static let title: LocalizedStringResource = "Add Bookmark to Marks"
|
||||
static let description = IntentDescription("Save a link to your Marks bookmarks.")
|
||||
static let openAppWhenRun = false
|
||||
|
||||
@Parameter(title: "URL")
|
||||
var url: URL
|
||||
|
||||
@Parameter(title: "Title")
|
||||
var name: String?
|
||||
|
||||
@Parameter(title: "Tags")
|
||||
var tags: [String]?
|
||||
|
||||
static var parameterSummary: some ParameterSummary {
|
||||
Summary("Add \(\.$url) to Marks") {
|
||||
\.$name
|
||||
\.$tags
|
||||
}
|
||||
}
|
||||
|
||||
func perform() async throws -> some IntentResult & ReturnsValue<BookmarkEntity> & ProvidesDialog & ShowsSnippetView {
|
||||
let api = try MarksIntent.api()
|
||||
let create = BookmarkCreate(
|
||||
url: url.absoluteString,
|
||||
title: name ?? "",
|
||||
tagNames: tags ?? [],
|
||||
isArchived: false,
|
||||
unread: true,
|
||||
shared: false
|
||||
)
|
||||
let bookmark = try await api.createBookmark(create)
|
||||
let entity = BookmarkEntity(from: bookmark)
|
||||
MarksIntent.refreshWidgets(adding: bookmark)
|
||||
return .result(
|
||||
value: entity,
|
||||
dialog: IntentDialog("Saved \(entity.title) to Marks."),
|
||||
view: BookmarkSnippetView(entity: entity)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Show unread
|
||||
|
||||
/// Returns the user's unread bookmarks — "what's unread in Marks?"
|
||||
struct ShowUnreadIntent: AppIntent {
|
||||
static let title: LocalizedStringResource = "Show Unread Bookmarks"
|
||||
static let description = IntentDescription("List the bookmarks you haven't read yet.")
|
||||
|
||||
func perform() async throws -> some IntentResult & ReturnsValue<[BookmarkEntity]> & ProvidesDialog {
|
||||
let api = try MarksIntent.api()
|
||||
let response = try await api.fetchBookmarks(limit: 10, unread: true)
|
||||
let entities = response.results.map(BookmarkEntity.init(from:))
|
||||
let dialog: IntentDialog = entities.isEmpty
|
||||
? "You have no unread bookmarks."
|
||||
: "You have \(entities.count) unread bookmark\(entities.count == 1 ? "" : "s")."
|
||||
return .result(value: entities, dialog: dialog)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Summarize (AI)
|
||||
|
||||
/// Produces an AI summary of a bookmark via the bundled ClaudeService.
|
||||
struct SummarizeBookmarkIntent: AppIntent {
|
||||
static let title: LocalizedStringResource = "Summarize Bookmark"
|
||||
static let description = IntentDescription("Get an AI summary of a saved bookmark.")
|
||||
|
||||
@Parameter(title: "Bookmark")
|
||||
var target: BookmarkEntity
|
||||
|
||||
static var parameterSummary: some ParameterSummary {
|
||||
Summary("Summarize \(\.$target)")
|
||||
}
|
||||
|
||||
func perform() async throws -> some IntentResult & ReturnsValue<String> & ProvidesDialog & ShowsSnippetView {
|
||||
let claude = ClaudeService()
|
||||
let (summary, _) = try await claude.enrich(bookmark: target.makeBookmark())
|
||||
return .result(
|
||||
value: summary,
|
||||
dialog: IntentDialog("\(summary)"),
|
||||
view: SummarySnippetView(title: target.title, host: target.host, summary: summary)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - App Shortcuts
|
||||
|
||||
struct MarksShortcuts: AppShortcutsProvider {
|
||||
static var appShortcuts: [AppShortcut] {
|
||||
AppShortcut(
|
||||
intent: AddBookmarkIntent(),
|
||||
phrases: [
|
||||
"Add a bookmark to \(.applicationName)",
|
||||
"Save this link to \(.applicationName)"
|
||||
],
|
||||
shortTitle: "Add Bookmark",
|
||||
systemImageName: "bookmark"
|
||||
)
|
||||
AppShortcut(
|
||||
intent: SearchMarksIntent(),
|
||||
phrases: [
|
||||
"Search \(.applicationName)",
|
||||
"Search my bookmarks in \(.applicationName)"
|
||||
],
|
||||
shortTitle: "Search",
|
||||
systemImageName: "magnifyingglass"
|
||||
)
|
||||
AppShortcut(
|
||||
intent: ShowUnreadIntent(),
|
||||
phrases: [
|
||||
"Show unread in \(.applicationName)",
|
||||
"What's unread in \(.applicationName)"
|
||||
],
|
||||
shortTitle: "Unread",
|
||||
systemImageName: "envelope.badge"
|
||||
)
|
||||
AppShortcut(
|
||||
intent: SummarizeBookmarkIntent(),
|
||||
phrases: [
|
||||
"Summarize a bookmark in \(.applicationName)",
|
||||
"Summarize this with \(.applicationName)"
|
||||
],
|
||||
shortTitle: "Summarize",
|
||||
systemImageName: "sparkles"
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user