Every bookmark failed to reach the Spotlight index. Each item died in translation with "Provided object for field url is of class NSURL, expected class: NSString", so on-device search and Ask Your Bookmarks were retrieving from an index that was effectively empty. Left to itself, App Intents indexes BookmarkEntity's `url` property under the attribute set's own `url` key, which Spotlight's Cascade translator types as NSString. Giving the property an explicit `indexingKey: \.contentURL` sends it to a URL-typed field instead — and contentURL is the right field for "where this content lives" regardless. Keeping the property a URL rather than retyping it to String means existing Shortcuts that read it are unaffected. The attribute set now sets contentURL too, and the retrieval side reads it back, so the round trip stays on one field. Why it went unnoticed: translation happens after `indexAppEntities` returns, so indexing logged success the whole time. Measured on the simulator against the live library — 202 translation failures per launch before, 0 after. Adds SpotlightRetrievalTests, which indexes a bookmark and retrieves it through the assistant's own path. Nothing weaker would have caught this, since the failure was silent at every layer above the index. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JgLHztZGaEHvS3KNeGQmRM
133 lines
4.6 KiB
Swift
133 lines
4.6 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
|
|
|
|
/// The explicit `indexingKey` is load-bearing. Left to itself App Intents
|
|
/// indexes this property under the attribute set's own `url` key, and
|
|
/// Spotlight's Cascade translator types that field as NSString — so every
|
|
/// item failed with "Provided object for field url is of class NSURL,
|
|
/// expected class: NSString" and nothing reached the index. `contentURL`
|
|
/// is URL-typed there, and is the right field for "where this lives"
|
|
/// anyway. Routing it there keeps the property a URL for Shortcuts.
|
|
///
|
|
/// The failure is silent: translation happens after `indexAppEntities`
|
|
/// returns, so indexing logs success either way.
|
|
@Property(title: "URL", indexingKey: \.contentURL)
|
|
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.contentURL = 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:))
|
|
}
|
|
}
|