Files
linkding-ios/Marks/Intents/MarksAppIntents.swift
Krishna Kumar 389d615c8b
All checks were successful
CI / build-and-deploy (push) Successful in 24s
feat(intents): add App Intents + Siri integration
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>
2026-06-16 16:10:17 -05:00

178 lines
6.1 KiB
Swift

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"
)
}
}