Files
linkding-ios/Marks/Intents/BookmarkEntity.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

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