Fix Spotlight indexing: route the entity URL to contentURL
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
This commit is contained in:
co-authored by
Claude Opus 5
parent
76df237473
commit
69556afc08
@@ -25,6 +25,7 @@
|
||||
457FCE503CCA82C5F27C6C90 /* Bookmark.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CA428181B35885F7D9F4D55 /* Bookmark.swift */; };
|
||||
50F3BED92EBA34F863C9F8A0 /* LibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7510EB352E624C7C9656EA33 /* LibraryView.swift */; };
|
||||
55CDFFAB5530D08861F85363 /* LibraryGridView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1BF2B010DADCCFBC282D37F0 /* LibraryGridView.swift */; };
|
||||
59E46C9653C9033F5BB1CEC7 /* SpotlightRetrievalTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2526E22D8EC5453358F0FCF9 /* SpotlightRetrievalTests.swift */; };
|
||||
5D86F3F0F603B248776916C7 /* BookmarksViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBFB5EFC9764B22A2622EA4A /* BookmarksViewModel.swift */; };
|
||||
5ED7F0AB24549BA01757A39C /* PodcastPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4EB8C63735A267B81030CB5 /* PodcastPlayerView.swift */; };
|
||||
66D5D90A5FAF842BCA0FE72D /* PodcastRequests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D27A97922BAEBDC9C5A7385C /* PodcastRequests.swift */; };
|
||||
@@ -136,6 +137,7 @@
|
||||
22E006A11D594BFC00A9C4B4 /* OnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingView.swift; sourceTree = "<group>"; };
|
||||
23F172EC9977CD5C51B228B9 /* MarksWidget.appex */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = "wrapper.app-extension"; path = MarksWidget.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
240DBB87940F8D255A812EB2 /* BookmarkActions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkActions.swift; sourceTree = "<group>"; };
|
||||
2526E22D8EC5453358F0FCF9 /* SpotlightRetrievalTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpotlightRetrievalTests.swift; sourceTree = "<group>"; };
|
||||
41DDBB04346F3BF06DE233D2 /* SpotlightBookmarkSearch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpotlightBookmarkSearch.swift; sourceTree = "<group>"; };
|
||||
47CB3AAED5B64809B06A9650 /* RecentPodcastsWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecentPodcastsWidget.swift; sourceTree = "<group>"; };
|
||||
49685B8F3FEC72E8CF75843E /* AnalyticsService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsService.swift; sourceTree = "<group>"; };
|
||||
@@ -244,6 +246,7 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
7623601C25E481DF58371F2A /* AppIntentsTests.swift */,
|
||||
2526E22D8EC5453358F0FCF9 /* SpotlightRetrievalTests.swift */,
|
||||
);
|
||||
path = MarksTests;
|
||||
sourceTree = "<group>";
|
||||
@@ -528,6 +531,7 @@
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
FD656A44CEE8AE28B136AD85 /* AppIntentsTests.swift in Sources */,
|
||||
59E46C9653C9033F5BB1CEC7 /* SpotlightRetrievalTests.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
|
||||
@@ -17,7 +17,17 @@ struct BookmarkEntity: AppEntity, IndexedEntity {
|
||||
@Property(title: "Title")
|
||||
var title: String
|
||||
|
||||
@Property(title: "URL")
|
||||
/// 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")
|
||||
@@ -53,7 +63,7 @@ struct BookmarkEntity: AppEntity, IndexedEntity {
|
||||
attrs.title = title
|
||||
attrs.contentDescription = details.isEmpty ? summary : details
|
||||
attrs.keywords = tags
|
||||
attrs.url = url
|
||||
attrs.contentURL = url
|
||||
return attrs
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ enum SpotlightBookmarkSearch {
|
||||
Log.spotlight.debug("Search query=\(rawQuery, privacy: .public) predicate=\(queryString, privacy: .public)")
|
||||
|
||||
let context = CSSearchQueryContext()
|
||||
context.fetchAttributes = ["title", "contentDescription", "keywords", "url"]
|
||||
context.fetchAttributes = ["title", "contentDescription", "keywords", "contentURL"]
|
||||
let query = CSSearchQuery(queryString: queryString, queryContext: context)
|
||||
|
||||
var out: [RetrievedBookmark] = []
|
||||
@@ -33,9 +33,9 @@ enum SpotlightBookmarkSearch {
|
||||
let a = result.item.attributeSet
|
||||
out.append(RetrievedBookmark(
|
||||
title: a.title ?? "Untitled",
|
||||
host: a.url?.host() ?? "",
|
||||
host: a.contentURL?.host() ?? "",
|
||||
description: a.contentDescription ?? "",
|
||||
url: a.url?.absoluteString ?? ""
|
||||
url: a.contentURL?.absoluteString ?? ""
|
||||
))
|
||||
if out.count >= limit { break }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import Testing
|
||||
import CoreSpotlight
|
||||
@testable import Marks
|
||||
|
||||
/// End-to-end guard for the whole Spotlight path: index a bookmark, then
|
||||
/// retrieve it the way the on-device assistant does.
|
||||
///
|
||||
/// Worth keeping. The bug this was written for made every item fail to
|
||||
/// translate into the index while `indexAppEntities` still reported success,
|
||||
/// so nothing short of a round trip would have caught it.
|
||||
struct SpotlightRetrievalTests {
|
||||
@Test func indexedBookmarkIsRetrievable() async throws {
|
||||
let b = Bookmark(
|
||||
id: 999_001,
|
||||
url: "https://github.com/example/zqxjkltest",
|
||||
title: "Zqxjkltest concurrency notes",
|
||||
description: "A distinctive probe document about zqxjkltest.",
|
||||
tagNames: ["zqxjkltest"],
|
||||
dateAdded: Date(), dateModified: Date(),
|
||||
isArchived: false, unread: false, shared: false,
|
||||
websiteTitle: nil, websiteDescription: nil,
|
||||
faviconUrl: nil, previewImageUrl: nil,
|
||||
aiSummary: nil, aiTags: nil
|
||||
)
|
||||
await SpotlightIndexer.index([b])
|
||||
var hits: [RetrievedBookmark] = []
|
||||
for _ in 0..<20 {
|
||||
try? await Task.sleep(for: .milliseconds(700))
|
||||
hits = await SpotlightBookmarkSearch.run(query: "zqxjkltest", limit: 5)
|
||||
if !hits.isEmpty { break }
|
||||
}
|
||||
await SpotlightIndexer.remove(ids: [999_001])
|
||||
#expect(!hits.isEmpty, "Spotlight returned no hits for an indexed bookmark")
|
||||
let hit = try #require(hits.first)
|
||||
#expect(hit.title.contains("Zqxjkltest"))
|
||||
#expect(hit.url == "https://github.com/example/zqxjkltest",
|
||||
"url did not survive the round trip: \(hit.url)")
|
||||
#expect(hit.host == "github.com", "host was \(hit.host)")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user