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:
66
Marks/Intents/IntentSupport.swift
Normal file
66
Marks/Intents/IntentSupport.swift
Normal file
@@ -0,0 +1,66 @@
|
||||
import Foundation
|
||||
import AppIntents
|
||||
|
||||
/// Which top-level tab the app is showing. Used so an intent can switch tabs.
|
||||
enum AppTab: Hashable {
|
||||
case bookmarks, tags, search
|
||||
}
|
||||
|
||||
/// Bridges App Intents (which run in the main app process, since there is no
|
||||
/// separate AppIntents extension target) to the live SwiftUI scene.
|
||||
///
|
||||
/// Intents mutate this shared, observable singleton; `MainContainer` and
|
||||
/// `SearchView` observe it and react (present a browser, switch to the search
|
||||
/// tab, etc.). This is the standard "intent → in-app navigation" pattern when
|
||||
/// perform() runs in-process.
|
||||
@MainActor
|
||||
@Observable
|
||||
final class IntentRouter {
|
||||
static let shared = IntentRouter()
|
||||
private init() {}
|
||||
|
||||
/// A bookmark URL an intent asked to open in the in-app browser.
|
||||
var openBookmarkURL: String?
|
||||
|
||||
/// A query an intent asked the app to search for.
|
||||
var searchRequest: String?
|
||||
|
||||
func openBookmark(url: String) { openBookmarkURL = url }
|
||||
func search(_ query: String) { searchRequest = query }
|
||||
}
|
||||
|
||||
/// Errors surfaced to Siri / Shortcuts when an intent can't run.
|
||||
enum MarksIntentError: Error, CustomLocalizedStringResourceConvertible {
|
||||
case notConfigured
|
||||
case invalidURL
|
||||
|
||||
var localizedStringResource: LocalizedStringResource {
|
||||
switch self {
|
||||
case .notConfigured: "Open Marks and connect to your Linkding server first."
|
||||
case .invalidURL: "That doesn't look like a valid link."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared plumbing for the intents: build an API client from saved config and
|
||||
/// keep the widgets in sync after mutations.
|
||||
enum MarksIntent {
|
||||
/// Builds a `LinkdingAPI` from the saved server config, or throws a
|
||||
/// user-facing error if the app has never been connected.
|
||||
static func api() throws -> LinkdingAPI {
|
||||
guard let config = ServerConfig.load() else { throw MarksIntentError.notConfigured }
|
||||
return LinkdingAPI(config: config)
|
||||
}
|
||||
|
||||
/// Prepend a freshly-saved bookmark into the widget cache so the Recent
|
||||
/// widget reflects it immediately (mirrors `BookmarksViewModel.load`).
|
||||
static func refreshWidgets(adding b: Bookmark) {
|
||||
var items = WidgetDataStore.loadBookmarks()
|
||||
items.removeAll { $0.url == b.url }
|
||||
items.insert(
|
||||
WidgetBookmark(url: b.url, title: b.displayTitle, domain: b.domain, faviconUrl: b.faviconUrl),
|
||||
at: 0
|
||||
)
|
||||
WidgetDataStore.saveBookmarks(Array(items.prefix(20)))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user