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>
178 lines
6.1 KiB
Swift
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"
|
|
)
|
|
}
|
|
}
|