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>
123 lines
3.9 KiB
Swift
123 lines
3.9 KiB
Swift
import Foundation
|
|
import AppIntents
|
|
import CoreSpotlight
|
|
|
|
/// A Linkding bookmark exposed to Siri, Spotlight, Shortcuts, and the
|
|
/// Apple-Intelligence "Use Model" action.
|
|
///
|
|
/// Conforms to `IndexedEntity` so its title / description / tags become
|
|
/// Spotlight-searchable, which is what lets Siri answer "find my bookmark
|
|
/// about Swift concurrency". The identifier is the Linkding server id (`Int`),
|
|
/// which is stable across devices.
|
|
struct BookmarkEntity: AppEntity, IndexedEntity {
|
|
static let typeDisplayRepresentation = TypeDisplayRepresentation(name: "Bookmark")
|
|
|
|
let id: Int
|
|
|
|
@Property(title: "Title")
|
|
var title: String
|
|
|
|
@Property(title: "URL")
|
|
var url: URL
|
|
|
|
@Property(title: "Website")
|
|
var host: String
|
|
|
|
@Property(title: "Description")
|
|
var details: String
|
|
|
|
@Property(title: "Tags")
|
|
var tags: [String]
|
|
|
|
@Property(title: "Unread")
|
|
var unread: Bool
|
|
|
|
/// AI-generated summary (when one has been produced). Exposed so the
|
|
/// Shortcuts "Use Model" action and Siri can reason over it.
|
|
@Property(title: "Summary")
|
|
var summary: String?
|
|
|
|
static let defaultQuery = BookmarkQuery()
|
|
|
|
var displayRepresentation: DisplayRepresentation {
|
|
DisplayRepresentation(
|
|
title: "\(title)",
|
|
subtitle: "\(host)",
|
|
image: .init(systemName: "bookmark.fill")
|
|
)
|
|
}
|
|
|
|
/// Rich Spotlight attributes so semantic search has real content to match.
|
|
var attributeSet: CSSearchableItemAttributeSet {
|
|
let attrs = CSSearchableItemAttributeSet(contentType: .url)
|
|
attrs.title = title
|
|
attrs.contentDescription = details.isEmpty ? summary : details
|
|
attrs.keywords = tags
|
|
attrs.url = url
|
|
return attrs
|
|
}
|
|
}
|
|
|
|
extension BookmarkEntity {
|
|
init(from b: Bookmark) {
|
|
self.id = b.id
|
|
self.title = b.displayTitle
|
|
self.url = URL(string: b.url) ?? URL(string: "https://example.invalid")!
|
|
self.host = b.domain
|
|
self.details = b.contentExcerpt ?? ""
|
|
self.tags = b.tagNames
|
|
self.unread = b.unread
|
|
self.summary = b.aiSummary
|
|
}
|
|
|
|
/// Reconstruct a minimal `Bookmark` from the entity — enough for the
|
|
/// AI enrichment call, which only reads url/title/tags.
|
|
func makeBookmark() -> Bookmark {
|
|
Bookmark(
|
|
id: id,
|
|
url: url.absoluteString,
|
|
title: title,
|
|
description: details,
|
|
tagNames: tags,
|
|
dateAdded: Date(),
|
|
dateModified: Date(),
|
|
isArchived: false,
|
|
unread: unread,
|
|
shared: false,
|
|
websiteTitle: nil,
|
|
websiteDescription: nil,
|
|
faviconUrl: nil,
|
|
previewImageUrl: nil,
|
|
aiSummary: summary,
|
|
aiTags: nil
|
|
)
|
|
}
|
|
}
|
|
|
|
/// Looks bookmarks up by id and resolves free-text queries by hitting the
|
|
/// Linkding server live — the recommended path for data that lives on a
|
|
/// server and changes too often to pre-index.
|
|
struct BookmarkQuery: EntityStringQuery {
|
|
func entities(for identifiers: [Int]) async throws -> [BookmarkEntity] {
|
|
let api = try MarksIntent.api()
|
|
// Linkding has no batch-by-id endpoint; pull a page and filter.
|
|
let response = try await api.fetchBookmarks(limit: 200)
|
|
let wanted = Set(identifiers)
|
|
return response.results
|
|
.filter { wanted.contains($0.id) }
|
|
.map(BookmarkEntity.init(from:))
|
|
}
|
|
|
|
func entities(matching string: String) async throws -> [BookmarkEntity] {
|
|
let api = try MarksIntent.api()
|
|
let response = try await api.fetchBookmarks(search: string, limit: 25)
|
|
return response.results.map(BookmarkEntity.init(from:))
|
|
}
|
|
|
|
func suggestedEntities() async throws -> [BookmarkEntity] {
|
|
let api = try MarksIntent.api()
|
|
let response = try await api.fetchBookmarks(limit: 10)
|
|
return response.results.map(BookmarkEntity.init(from:))
|
|
}
|
|
}
|